1848 lignes
53 KiB
JavaScript
1848 lignes
53 KiB
JavaScript
(function () {
|
|
var pluginId = "68540b76-ee74-436d-85ff-2abc884bbea6";
|
|
var copyLabel = "Copy Stream URL";
|
|
var actionLabel = "ShareLink";
|
|
var clientVersion = "1.0.6-ui-1";
|
|
var allowedItemStorageKey = "sharelinks.allowedItemId";
|
|
var guestClassName = "sharelinks-guest";
|
|
var hiddenAttr = "data-sharelinks-hidden";
|
|
var injectedAttr = "data-sharelinks-injected";
|
|
var configPromise = null;
|
|
var userPromise = null;
|
|
var userPromiseUserId = null;
|
|
var guestStatePromise = null;
|
|
var guestStatePromiseUserId = null;
|
|
var booted = false;
|
|
var scanQueued = false;
|
|
var bootRetry = null;
|
|
var observer = null;
|
|
var menuContext = null;
|
|
var shareableItemTypes = [
|
|
"movie",
|
|
"series",
|
|
"season",
|
|
"episode",
|
|
"video",
|
|
"photo",
|
|
"boxset",
|
|
];
|
|
var menuTriggerSelector =
|
|
'[data-action="menu"], .btnMoreCommands, .btnCardOptions, .cardOverlayButton';
|
|
var itemTypeCache = {};
|
|
var itemTypeInFlight = {};
|
|
var shareTagPattern = /^sharelinks-[0-9a-f]{32}$/i;
|
|
var tagHiddenAttr = "data-sharelinks-tag-hidden";
|
|
var historyPatched = false;
|
|
var itemMenuActionIds = [
|
|
"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 },
|
|
{ label: "2 hours", hours: 2 },
|
|
{ label: "4 hours", hours: 4 },
|
|
{ label: "6 hours", hours: 6 },
|
|
{ label: "12 hours", hours: 12 },
|
|
{ label: "1 day", hours: 24 },
|
|
{ label: "2 days", hours: 48 },
|
|
{ label: "7 days", hours: 168 },
|
|
];
|
|
|
|
function isFrench() {
|
|
var lang =
|
|
document.documentElement.getAttribute("lang") ||
|
|
(navigator && (navigator.language || navigator.userLanguage)) ||
|
|
"en";
|
|
return /^fr/i.test(lang);
|
|
}
|
|
|
|
var STRINGS = {
|
|
en: {
|
|
modalTitle: "Create guest link",
|
|
modalBody: "Choose how long this link should stay valid.",
|
|
dateLabel: "Or pick an exact expiry date and time:",
|
|
create: "Create",
|
|
cancel: "Cancel",
|
|
copy: "Copy",
|
|
done: "Done",
|
|
pickDateFirst: "Pick a date and time first.",
|
|
dateInvalid: "That date is not valid.",
|
|
pickFuture: "Pick a time in the future.",
|
|
multiUseLabel: "Let several people use this link",
|
|
multiUseHint:
|
|
"The link keeps working for anyone you send it to until it expires, instead of dying once the first person opens it.",
|
|
multiUseLimit: "Up to {count} of them can watch at the same time.",
|
|
resultMultiUseNote:
|
|
"Anyone you send this link to can open it until it expires.",
|
|
cannotDetermineItem:
|
|
"Could not determine which item to share. Open the item page and retry.",
|
|
adminOnly: "ShareLinks is available to administrators only.",
|
|
disabled: "ShareLinks is disabled.",
|
|
noShareUrl: "The server did not return a share URL.",
|
|
couldNotCreate: "Could not create a guest link.",
|
|
copiedNote: "The link was copied to your clipboard.",
|
|
notCopiedNote:
|
|
"The link was created, but the browser blocked automatic clipboard access.",
|
|
resultCopiedTitle: "Share link copied",
|
|
resultCreatedTitle: "Share link created",
|
|
toastCopied: "Share link copied.",
|
|
toastManual: "Select and copy the link manually.",
|
|
hour: "hour",
|
|
hours: "hours",
|
|
day: "day",
|
|
days: "days",
|
|
},
|
|
fr: {
|
|
modalTitle: "Créer un lien invité",
|
|
modalBody: "Choisissez la durée de validité de ce lien.",
|
|
dateLabel: "Ou choisissez une date et une heure d'expiration précises :",
|
|
create: "Créer",
|
|
cancel: "Annuler",
|
|
copy: "Copier",
|
|
done: "Terminé",
|
|
pickDateFirst: "Choisissez d'abord une date et une heure.",
|
|
dateInvalid: "Cette date n'est pas valide.",
|
|
pickFuture: "Choisissez une date dans le futur.",
|
|
multiUseLabel: "Autoriser plusieurs personnes à utiliser ce lien",
|
|
multiUseHint:
|
|
"Le lien reste valable pour toutes les personnes à qui vous l'envoyez jusqu'à son expiration, au lieu de mourir dès la première ouverture.",
|
|
multiUseLimit:
|
|
"Jusqu'a {count} d'entre elles peuvent regarder en meme temps.",
|
|
resultMultiUseNote:
|
|
"Toutes les personnes à qui vous envoyez ce lien peuvent l'ouvrir jusqu'à son expiration.",
|
|
cannotDetermineItem:
|
|
"Impossible de déterminer l'élément à partager. Ouvrez la page du média et réessayez.",
|
|
adminOnly: "ShareLinks est réservé aux administrateurs.",
|
|
disabled: "ShareLinks est désactivé.",
|
|
noShareUrl: "Le serveur n'a pas renvoyé de lien de partage.",
|
|
couldNotCreate: "Impossible de créer le lien invité.",
|
|
copiedNote: "Le lien a été copié dans le presse-papiers.",
|
|
notCopiedNote:
|
|
"Le lien a été créé, mais le navigateur a bloqué l'accès automatique au presse-papiers.",
|
|
resultCopiedTitle: "Lien de partage copié",
|
|
resultCreatedTitle: "Lien de partage créé",
|
|
toastCopied: "Lien de partage copié.",
|
|
toastManual: "Sélectionnez et copiez le lien manuellement.",
|
|
hour: "heure",
|
|
hours: "heures",
|
|
day: "jour",
|
|
days: "jours",
|
|
},
|
|
};
|
|
|
|
function t(key) {
|
|
var lang = isFrench() ? "fr" : "en";
|
|
return (STRINGS[lang] && STRINGS[lang][key]) || STRINGS.en[key] || key;
|
|
}
|
|
|
|
function durationLabel(hours) {
|
|
if (hours < 24) {
|
|
return hours + " " + t(hours === 1 ? "hour" : "hours");
|
|
}
|
|
var d = hours / 24;
|
|
return d + " " + t(d === 1 ? "day" : "days");
|
|
}
|
|
|
|
window.ShareLinksClientVersion = clientVersion;
|
|
|
|
function ready() {
|
|
return !!window.ApiClient && !!window.document && !!document.body;
|
|
}
|
|
|
|
function start() {
|
|
if (booted) {
|
|
return;
|
|
}
|
|
|
|
if (!ready()) {
|
|
if (!bootRetry) {
|
|
bootRetry = window.setTimeout(function () {
|
|
bootRetry = null;
|
|
start();
|
|
}, 250);
|
|
}
|
|
return;
|
|
}
|
|
|
|
booted = true;
|
|
installHooks();
|
|
scheduleWork();
|
|
}
|
|
|
|
function installHooks() {
|
|
if (!historyPatched) {
|
|
historyPatched = true;
|
|
patchHistory();
|
|
}
|
|
|
|
window.addEventListener("hashchange", scheduleWork, true);
|
|
window.addEventListener("popstate", scheduleWork, true);
|
|
|
|
document.addEventListener(
|
|
"pointerdown",
|
|
function (event) {
|
|
rememberMenuContext(event.target);
|
|
},
|
|
true
|
|
);
|
|
|
|
observer = new MutationObserver(scheduleWork);
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
|
|
window.setInterval(scheduleWork, 3000);
|
|
}
|
|
|
|
function patchHistory() {
|
|
var pushState = history.pushState;
|
|
var replaceState = history.replaceState;
|
|
|
|
history.pushState = function () {
|
|
var result = pushState.apply(this, arguments);
|
|
scheduleWork();
|
|
return result;
|
|
};
|
|
|
|
history.replaceState = function () {
|
|
var result = replaceState.apply(this, arguments);
|
|
scheduleWork();
|
|
return result;
|
|
};
|
|
}
|
|
|
|
function scheduleWork() {
|
|
if (scanQueued || !ready()) {
|
|
return;
|
|
}
|
|
|
|
scanQueued = true;
|
|
window.requestAnimationFrame(function () {
|
|
scanQueued = false;
|
|
refresh().catch(function () {
|
|
// Best effort only. The menu hook should never block the web UI.
|
|
});
|
|
});
|
|
}
|
|
|
|
async function refresh() {
|
|
rememberAllowedItemFromRoute();
|
|
await applyGuestLockdown();
|
|
await hideShareTagsFromNonAdmins();
|
|
await scanForMoreMenuActions();
|
|
}
|
|
|
|
function apiGet(path) {
|
|
return ApiClient.ajax({
|
|
type: "GET",
|
|
url: ApiClient.getUrl(path),
|
|
dataType: "json",
|
|
});
|
|
}
|
|
|
|
function apiPost(path, body) {
|
|
return ApiClient.ajax({
|
|
type: "POST",
|
|
url: ApiClient.getUrl(path),
|
|
dataType: "json",
|
|
contentType: "application/json",
|
|
data: JSON.stringify(body || {}),
|
|
});
|
|
}
|
|
|
|
function getConfig() {
|
|
if (!configPromise) {
|
|
configPromise = ApiClient.getPluginConfiguration(pluginId).catch(
|
|
function () {
|
|
return {};
|
|
}
|
|
);
|
|
}
|
|
return configPromise;
|
|
}
|
|
|
|
/**
|
|
* The web client is a single page app: signing out or switching accounts does
|
|
* not reload the document, so anything cached for "the current user" has to be
|
|
* keyed to the session it came from. Caching it for the lifetime of the page
|
|
* let an admin's verdict survive into the next user's session.
|
|
*/
|
|
function getCurrentUser() {
|
|
var userId = currentApiUserId();
|
|
if (!userPromise || userPromiseUserId !== userId) {
|
|
userPromiseUserId = userId;
|
|
userPromise = apiGet("Users/Me").catch(function () {
|
|
return null;
|
|
});
|
|
}
|
|
|
|
return userPromise;
|
|
}
|
|
|
|
function currentApiUserId() {
|
|
try {
|
|
return (
|
|
(window.ApiClient &&
|
|
ApiClient.getCurrentUserId &&
|
|
ApiClient.getCurrentUserId()) ||
|
|
""
|
|
);
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function getGuestState() {
|
|
var userId = currentApiUserId();
|
|
if (!guestStatePromise || guestStatePromiseUserId !== userId) {
|
|
guestStatePromiseUserId = userId;
|
|
guestStatePromise = apiGet("ShareLinks/GuestState").catch(function () {
|
|
return null;
|
|
});
|
|
}
|
|
|
|
return guestStatePromise;
|
|
}
|
|
|
|
function hideBlockedGuestMenuItems() {
|
|
var nodes = document.querySelectorAll(".actionSheetMenuItem");
|
|
Array.prototype.forEach.call(nodes, function (node) {
|
|
if (!node || node.getAttribute("data-sharelinks-blocked") === "1") {
|
|
return;
|
|
}
|
|
|
|
var dataId = String(node.getAttribute("data-id") || "").toLowerCase();
|
|
var label = getVisibleLabel(node).toLowerCase();
|
|
var icon = node.querySelector(".material-icons");
|
|
var iconClass = icon ? String(icon.className || "") : "";
|
|
|
|
var blocked =
|
|
dataId === "playlist" ||
|
|
dataId === "addtoplaylist" ||
|
|
dataId === "addtocollection" ||
|
|
label.indexOf("liste de lecture") >= 0 ||
|
|
label.indexOf("playlist") >= 0 ||
|
|
label.indexOf("add to collection") >= 0 ||
|
|
label.indexOf("ajouter à la collection") >= 0 ||
|
|
(iconClass.indexOf("playlist_add") >= 0 &&
|
|
dataId !== "queue" &&
|
|
dataId !== "queuenext");
|
|
|
|
if (blocked) {
|
|
node.setAttribute("data-sharelinks-blocked", "1");
|
|
node.style.setProperty("display", "none", "important");
|
|
}
|
|
});
|
|
}
|
|
|
|
async function applyGuestLockdown() {
|
|
var context = await getGuestContext();
|
|
if (!context.locked) {
|
|
return;
|
|
}
|
|
|
|
ensureGuestStyle();
|
|
ensurePluginHideStyle(context.hiddenSelectors);
|
|
hideGuestControls();
|
|
hideBlockedGuestMenuItems();
|
|
|
|
// UX-only lockdown: the guest user's real access boundary is still the
|
|
// server-side policy and item tags. This just keeps the web client out
|
|
// of the user's way, while still letting the guest browse into the
|
|
// shared tree (series -> season -> episode) when the server says the
|
|
// item is visible to them.
|
|
if (!context.allowedItemId) {
|
|
return;
|
|
}
|
|
|
|
var verdict = await checkAllowedLocation(context.allowedItemId);
|
|
if (verdict === false) {
|
|
navigateToItem(context.allowedItemId);
|
|
}
|
|
// verdict === true: allowed, do nothing.
|
|
// verdict === null: check still in flight or route is not item-scoped
|
|
// in a way we can verify yet; do nothing to avoid flicker.
|
|
}
|
|
|
|
async function getGuestContext() {
|
|
var config = await getConfig();
|
|
var user = await getCurrentUser();
|
|
var state = await getGuestState();
|
|
var prefix =
|
|
config && config.GuestUsernamePrefix
|
|
? String(config.GuestUsernamePrefix)
|
|
: "share-";
|
|
var username = user && user.Name ? String(user.Name) : "";
|
|
var lockdownEnabled = config && config.GuestModeLockdownEnabled !== false;
|
|
if (state && state.lockdownEnabled === false) {
|
|
lockdownEnabled = false;
|
|
}
|
|
|
|
var locked =
|
|
lockdownEnabled &&
|
|
((username && username.indexOf(prefix) === 0) ||
|
|
!!(
|
|
state &&
|
|
(state.IsGuest === true ||
|
|
state.isGuest === true ||
|
|
state.GuestUserId ||
|
|
state.guestUserId)
|
|
));
|
|
return {
|
|
locked: locked,
|
|
allowedItemId:
|
|
extractAllowedItemId(state) ||
|
|
sessionStorage.getItem(allowedItemStorageKey) ||
|
|
null,
|
|
username: username,
|
|
prefix: prefix,
|
|
lockdownEnabled: lockdownEnabled,
|
|
hiddenSelectors:
|
|
(state && (state.HiddenSelectors || state.hiddenSelectors)) || "",
|
|
};
|
|
}
|
|
|
|
function extractAllowedItemId(state) {
|
|
if (!state) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
state.AllowedItemId ||
|
|
state.allowedItemId ||
|
|
state.ItemId ||
|
|
state.itemId ||
|
|
state.ShareItemId ||
|
|
state.shareItemId ||
|
|
null
|
|
);
|
|
}
|
|
|
|
function ensureGuestStyle() {
|
|
if (document.body.classList.contains(guestClassName)) {
|
|
return;
|
|
}
|
|
|
|
document.body.classList.add(guestClassName);
|
|
if (document.getElementById("ShareLinksGuestStyle")) {
|
|
return;
|
|
}
|
|
|
|
var style = document.createElement("style");
|
|
style.id = "ShareLinksGuestStyle";
|
|
style.textContent =
|
|
"body." +
|
|
guestClassName +
|
|
" [" +
|
|
hiddenAttr +
|
|
'="1"],' +
|
|
" body." +
|
|
guestClassName +
|
|
" .headerHomeButton," +
|
|
" body." +
|
|
guestClassName +
|
|
" .mainDrawerButton," +
|
|
" body." +
|
|
guestClassName +
|
|
" .headerSearchButton," +
|
|
" body." +
|
|
guestClassName +
|
|
' [data-action="addtoplaylist"],' +
|
|
" body." +
|
|
guestClassName +
|
|
' [data-action="addtocollection"],' +
|
|
" body." +
|
|
guestClassName +
|
|
' [data-id="playlist"],' +
|
|
// The Modern layout (the default from Jellyfin 12) renders its own header
|
|
// instead of .skinHeader, so its controls need their own selectors here.
|
|
" body." +
|
|
guestClassName +
|
|
' .MuiAppBar-root a[href^="#/"],' +
|
|
" body." +
|
|
guestClassName +
|
|
' .MuiAppBar-root [aria-controls="app-user-menu"],' +
|
|
" body." +
|
|
guestClassName +
|
|
" .MuiAppBar-root .MuiToolbar-root > button:first-child," +
|
|
" body." +
|
|
guestClassName +
|
|
' [data-id="addtocollection"] { display: none !important; }' +
|
|
// Cards stay clickable so guests can navigate season/episode cards on a shared series;
|
|
// checkAllowedLocation() verifies the destination server-side and redirects if disallowed.
|
|
" body." +
|
|
guestClassName +
|
|
" #castCollapsible a," +
|
|
" body." +
|
|
guestClassName +
|
|
" .detailsGroupItem a," +
|
|
" body." +
|
|
guestClassName +
|
|
" .genresGroup a," +
|
|
" body." +
|
|
guestClassName +
|
|
" .studiosGroup a," +
|
|
" body." +
|
|
guestClassName +
|
|
" .itemTags a," +
|
|
" body." +
|
|
guestClassName +
|
|
" .mediaInfoItem a { pointer-events: none !important; cursor: default !important; }";
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function ensurePluginHideStyle(selectors) {
|
|
var value = String(selectors || "")
|
|
.split(",")
|
|
.map(function (part) {
|
|
return part.trim();
|
|
})
|
|
.filter(Boolean);
|
|
var existing = document.getElementById("ShareLinksPluginHideStyle");
|
|
if (value.length === 0) {
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
return;
|
|
}
|
|
|
|
var css = value.join(", ") + " { display: none !important; }";
|
|
if (existing) {
|
|
if (existing.textContent !== css) {
|
|
existing.textContent = css;
|
|
}
|
|
return;
|
|
}
|
|
|
|
var style = document.createElement("style");
|
|
style.id = "ShareLinksPluginHideStyle";
|
|
style.textContent = css;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function hideGuestControls() {
|
|
var roots = [
|
|
document.querySelector(".skinHeader"),
|
|
document.querySelector(".mainDrawer"),
|
|
document.querySelector(".mainDrawerPanel"),
|
|
document.querySelector(".pageContainer"),
|
|
document.body,
|
|
].filter(Boolean);
|
|
var keywords = [
|
|
"home",
|
|
"search",
|
|
"library",
|
|
"settings",
|
|
"download",
|
|
"share",
|
|
];
|
|
|
|
roots.forEach(function (root) {
|
|
Array.from(
|
|
root.querySelectorAll('button, a, [role="button"], [role="menuitem"]')
|
|
).forEach(function (node) {
|
|
if (shouldHideNode(node, keywords)) {
|
|
node.setAttribute(hiddenAttr, "1");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function shouldHideNode(node, keywords) {
|
|
if (!node || node.getAttribute(hiddenAttr) === "1") {
|
|
return false;
|
|
}
|
|
|
|
var label = getVisibleLabel(node).toLowerCase();
|
|
if (!label) {
|
|
return false;
|
|
}
|
|
|
|
return keywords.some(function (word) {
|
|
return label.indexOf(word) >= 0;
|
|
});
|
|
}
|
|
|
|
function getVisibleLabel(node) {
|
|
return normalizeText(
|
|
node.getAttribute("aria-label") ||
|
|
node.getAttribute("title") ||
|
|
node.textContent ||
|
|
""
|
|
);
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return String(value || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function isItemGuid(value) {
|
|
return /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(
|
|
String(value || "").trim()
|
|
);
|
|
}
|
|
|
|
async function scanForMoreMenuActions() {
|
|
var user = await getCurrentUser();
|
|
if (!isAdministrator(user)) {
|
|
// Take back anything injected for a previous session rather than only
|
|
// skipping: a switch inside the SPA leaves the old DOM in place.
|
|
removeInjectedActions();
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function removeInjectedActions() {
|
|
Array.prototype.forEach.call(
|
|
document.querySelectorAll("[" + injectedAttr + '="1"]'),
|
|
function (node) {
|
|
node.remove();
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The share tag has to sit in the item's real Tags for Jellyfin's own tag policy
|
|
* to confine the guest, which means every user would otherwise see a
|
|
* "sharelinks-..." chip on the title and an entry in the library's tag filter.
|
|
* Administrators keep seeing them; for everyone else they are taken back out of
|
|
* the DOM. This is the web UI only - the tag is still in the API payload.
|
|
*/
|
|
async function hideShareTagsFromNonAdmins() {
|
|
// Deliberately only skipped for a confirmed administrator. If the lookup
|
|
// failed we do not know who this is, and the safe answer is to hide.
|
|
var user = await getCurrentUser();
|
|
if (isAdministrator(user)) {
|
|
return;
|
|
}
|
|
|
|
stripShareTagChips();
|
|
hideShareTagFilters();
|
|
}
|
|
|
|
function stripShareTagChips() {
|
|
Array.prototype.forEach.call(
|
|
document.querySelectorAll(".itemTags"),
|
|
function (container) {
|
|
var removed = 0;
|
|
Array.prototype.forEach.call(
|
|
container.querySelectorAll("a"),
|
|
function (chip) {
|
|
if (!shareTagPattern.test(normalizeText(chip.textContent))) {
|
|
return;
|
|
}
|
|
|
|
removeChipSeparator(chip);
|
|
chip.remove();
|
|
removed += 1;
|
|
}
|
|
);
|
|
|
|
if (removed > 0 && !container.querySelector("a")) {
|
|
// Nothing but the "Tags:" prefix would be left over.
|
|
container.textContent = "";
|
|
container.classList.add("hide");
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
/** Drops the ", " text node Jellyfin puts between two tag chips. */
|
|
function removeChipSeparator(chip) {
|
|
var separator = /^\s*,\s*$/;
|
|
var next = chip.nextSibling;
|
|
if (next && next.nodeType === 3 && separator.test(next.nodeValue)) {
|
|
next.parentNode.removeChild(next);
|
|
return;
|
|
}
|
|
|
|
var previous = chip.previousSibling;
|
|
if (
|
|
previous &&
|
|
previous.nodeType === 3 &&
|
|
separator.test(previous.nodeValue)
|
|
) {
|
|
previous.parentNode.removeChild(previous);
|
|
}
|
|
}
|
|
|
|
function hideShareTagFilters() {
|
|
Array.prototype.forEach.call(
|
|
document.querySelectorAll(".chkTagFilter"),
|
|
function (input) {
|
|
if (input.getAttribute(tagHiddenAttr) === "1") {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!shareTagPattern.test(String(input.getAttribute("data-filter") || ""))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
input.setAttribute(tagHiddenAttr, "1");
|
|
var row = input.closest("label") || input.parentElement;
|
|
if (row) {
|
|
row.style.setProperty("display", "none", "important");
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
function isCopyStreamUrlLabel(label) {
|
|
var value = normalizeText(label).toLowerCase();
|
|
if (!value) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
value === copyLabel.toLowerCase() ||
|
|
(value.indexOf("copy") >= 0 &&
|
|
value.indexOf("stream") >= 0 &&
|
|
value.indexOf("url") >= 0) ||
|
|
(value.indexOf("copier") >= 0 &&
|
|
value.indexOf("url") >= 0 &&
|
|
(value.indexOf("flux") >= 0 || value.indexOf("stream") >= 0))
|
|
);
|
|
}
|
|
|
|
function isItemMenuAction(node) {
|
|
if (!node || !node.getAttribute) {
|
|
return false;
|
|
}
|
|
|
|
var id = String(node.getAttribute("data-id") || "").toLowerCase();
|
|
if (id && itemMenuActionIds.indexOf(id) >= 0) {
|
|
return true;
|
|
}
|
|
|
|
return isCopyStreamUrlLabel(getVisibleLabel(node));
|
|
}
|
|
|
|
function findOpenActionContainer() {
|
|
var selectors = [".actionSheet", ".actionsheet", '[role="menu"]'];
|
|
|
|
for (var i = 0; i < selectors.length; i += 1) {
|
|
var nodes = document.querySelectorAll(selectors[i]);
|
|
for (var j = 0; j < nodes.length; j += 1) {
|
|
var node = nodes[j];
|
|
if (isVisible(node) && looksLikeActionContainer(node)) {
|
|
return node;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function looksLikeActionContainer(node) {
|
|
if (
|
|
node.querySelector(
|
|
'select, input:not([type="checkbox"]):not([type="radio"]), textarea'
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
var items = Array.from(
|
|
node.querySelectorAll(".actionSheetMenuItem, .actionsheetMenuItem")
|
|
).filter(isVisible);
|
|
if (items.length < 2 || items.length > 40) {
|
|
return false;
|
|
}
|
|
|
|
return items.some(function (item) {
|
|
return isItemMenuAction(item);
|
|
});
|
|
}
|
|
|
|
function findBestActionTemplate(container) {
|
|
var actions = Array.from(
|
|
container.querySelectorAll(".actionSheetMenuItem, .actionsheetMenuItem")
|
|
)
|
|
.filter(isVisible)
|
|
.filter(function (node) {
|
|
return node.getAttribute(injectedAttr) !== "1";
|
|
});
|
|
|
|
return actions.length ? actions[0] : null;
|
|
}
|
|
|
|
function isVisible(node) {
|
|
return !!(
|
|
node &&
|
|
(node.offsetWidth || node.offsetHeight || node.getClientRects().length)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 scroller =
|
|
container.querySelector(".actionSheetScroller") || template.parentElement;
|
|
if (!scroller) {
|
|
return;
|
|
}
|
|
|
|
var divider = document.createElement("div");
|
|
divider.className = "actionsheetDivider";
|
|
divider.setAttribute(injectedAttr, "1");
|
|
|
|
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;
|
|
}
|
|
|
|
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
|
|
);
|
|
|
|
return action;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
action.textContent = actionLabel;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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.
|
|
* The sheet's own close button exists only on TV layouts. On desktop, jellyfin-web
|
|
* closes a sheet when its dialog container gets a mousedown and a click aimed at
|
|
* the container itself (a click on the backdrop), and ignores Escape outside the
|
|
* TV layout. Escape stays as the last fallback.
|
|
*/
|
|
function closeActionSheet(container) {
|
|
var closeButton = container.querySelector(".btnCloseActionSheet");
|
|
if (closeButton) {
|
|
closeButton.click();
|
|
return;
|
|
}
|
|
|
|
var dialogContainer =
|
|
container.closest && container.closest(".dialogContainer");
|
|
if (dialogContainer) {
|
|
dialogContainer.dispatchEvent(
|
|
new MouseEvent("mousedown", { bubbles: true, cancelable: true })
|
|
);
|
|
dialogContainer.dispatchEvent(
|
|
new MouseEvent("click", { bubbles: true, cancelable: true })
|
|
);
|
|
return;
|
|
}
|
|
|
|
container.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
key: "Escape",
|
|
keyCode: 27,
|
|
which: 27,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
function parseItemIdFromUrl() {
|
|
var sources = [location.hash, location.search, location.href];
|
|
for (var i = 0; i < sources.length; i += 1) {
|
|
var text = sources[i];
|
|
var match = text.match(/[?&](?:id|itemId)=([^&#]+)/i);
|
|
if (match && match[1]) {
|
|
var decoded = decodeURIComponent(match[1]);
|
|
if (isItemGuid(decoded)) {
|
|
return decoded;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findItemIdInDocument() {
|
|
var selectors = [
|
|
"#itemDetailPage",
|
|
".detailPage",
|
|
".itemDetailPage",
|
|
"[data-itemid]",
|
|
"[data-id]",
|
|
];
|
|
|
|
for (var i = 0; i < selectors.length; i += 1) {
|
|
var nodes = document.querySelectorAll(selectors[i]);
|
|
for (var j = 0; j < nodes.length; j += 1) {
|
|
var id = readItemIdFromNode(nodes[j]);
|
|
if (id) {
|
|
return id;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readItemIdFromNode(node) {
|
|
if (!node || !node.getAttribute) {
|
|
return null;
|
|
}
|
|
|
|
var candidates = [
|
|
node.getAttribute("data-itemid"),
|
|
node.getAttribute("data-id"),
|
|
node.getAttribute("data-item-id"),
|
|
];
|
|
|
|
for (var i = 0; i < candidates.length; i += 1) {
|
|
if (isItemGuid(candidates[i])) {
|
|
return candidates[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isAdministrator(user) {
|
|
return !!(user && user.Policy && user.Policy.IsAdministrator === true);
|
|
}
|
|
|
|
function rememberAllowedItemFromRoute() {
|
|
if (!isDetailsOrPlaybackRoute()) {
|
|
return;
|
|
}
|
|
|
|
var itemId = parseItemIdFromUrl() || findItemIdInDocument();
|
|
if (itemId) {
|
|
sessionStorage.setItem(allowedItemStorageKey, itemId);
|
|
}
|
|
}
|
|
|
|
function isDetailsOrPlaybackRoute() {
|
|
return /#\/(?:details|video|playback|list|item)/i.test(location.hash || "");
|
|
}
|
|
|
|
var itemVisibilityCache = {};
|
|
var itemVisibilityInFlight = {};
|
|
|
|
function parseCandidateIdsFromUrl() {
|
|
var sources = [location.hash, location.search, location.href];
|
|
var keys = ["id", "seriesId", "parentId", "topParentId"];
|
|
var found = [];
|
|
|
|
for (var k = 0; k < keys.length; k += 1) {
|
|
var pattern = new RegExp("[?&]" + keys[k] + "=([^&#]+)", "i");
|
|
for (var i = 0; i < sources.length; i += 1) {
|
|
var text = sources[i];
|
|
var match = text.match(pattern);
|
|
if (match && match[1]) {
|
|
var decoded = decodeURIComponent(match[1]);
|
|
if (isItemGuid(decoded) && found.indexOf(decoded) < 0) {
|
|
found.push(decoded);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Returns true/false when the verdict for the current location is already
|
|
* known, or null while it is still being resolved (or the route is not one
|
|
* we verify). A true/false verdict for a given id is cached so the mutation
|
|
* observer re-running this on every DOM tick does not spam the API.
|
|
*/
|
|
function checkAllowedLocation(allowedItemId) {
|
|
var hash = location.hash || "";
|
|
if (/#\/(?:video|playback)/i.test(hash)) {
|
|
return true;
|
|
}
|
|
|
|
if (!/#\/(?:details|item|list)/i.test(hash)) {
|
|
return false;
|
|
}
|
|
|
|
var candidates = parseCandidateIdsFromUrl();
|
|
var normalizedAllowed = String(allowedItemId || "").toLowerCase();
|
|
|
|
if (candidates.length === 0) {
|
|
// Details/list route we could not extract an id from: fall back to
|
|
// the strict same-item check rather than guessing.
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
candidates.some(function (id) {
|
|
return id.toLowerCase() === normalizedAllowed;
|
|
})
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// None of the candidate ids is the shared item itself. Ask the server
|
|
// whether the current user (the guest) can actually fetch any of them -
|
|
// the AllowedTags policy is the real access boundary, so a successful
|
|
// fetch means the guest is allowed to be here (e.g. a season/episode
|
|
// inside a shared series or season).
|
|
return resolveServerVisibility(candidates);
|
|
}
|
|
|
|
function resolveServerVisibility(candidates) {
|
|
var pending = false;
|
|
|
|
for (var i = 0; i < candidates.length; i += 1) {
|
|
var id = candidates[i].toLowerCase();
|
|
if (Object.prototype.hasOwnProperty.call(itemVisibilityCache, id)) {
|
|
if (itemVisibilityCache[id] === true) {
|
|
return true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
pending = true;
|
|
if (!itemVisibilityInFlight[id]) {
|
|
itemVisibilityInFlight[id] = fetchItemVisibility(id);
|
|
}
|
|
}
|
|
|
|
// No definitive "allowed" verdict yet. If at least one candidate is
|
|
// still being checked, stay neutral (no redirect) until it settles.
|
|
// Only report a definitive rejection once every candidate has a
|
|
// cached, negative verdict.
|
|
return pending ? null : false;
|
|
}
|
|
|
|
function fetchItemVisibility(id) {
|
|
return Promise.resolve()
|
|
.then(function () {
|
|
var userId = ApiClient.getCurrentUserId();
|
|
return ApiClient.getItem(userId, id);
|
|
})
|
|
.then(function () {
|
|
itemVisibilityCache[id] = true;
|
|
return true;
|
|
})
|
|
.catch(function () {
|
|
itemVisibilityCache[id] = false;
|
|
return false;
|
|
})
|
|
.finally(function () {
|
|
delete itemVisibilityInFlight[id];
|
|
scheduleWork();
|
|
});
|
|
}
|
|
|
|
function navigateToItem(itemId) {
|
|
var target = "#/details?id=" + encodeURIComponent(itemId);
|
|
if (location.hash !== target) {
|
|
location.hash = target;
|
|
}
|
|
}
|
|
|
|
async function createGuestLink(itemId) {
|
|
if (!isItemGuid(itemId)) {
|
|
notify(t("cannotDetermineItem"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
var config = await getConfig();
|
|
var user = await getCurrentUser();
|
|
if (!isAdministrator(user)) {
|
|
notify(t("adminOnly"));
|
|
return;
|
|
}
|
|
|
|
if (config && config.Enabled === false) {
|
|
notify(t("disabled"));
|
|
return;
|
|
}
|
|
|
|
var result = await chooseExpiryHours(
|
|
config,
|
|
function (expiryHours, multiUse) {
|
|
var payload = {
|
|
itemId: itemId,
|
|
expiryHours: expiryHours,
|
|
oneUse: !multiUse,
|
|
};
|
|
|
|
var shareUrlPromise = apiPost(
|
|
"ShareLinks/Admin/Create",
|
|
payload
|
|
).then(function (response) {
|
|
var shareUrl = response && (response.ShareUrl || response.shareUrl);
|
|
if (!shareUrl) {
|
|
throw new Error(t("noShareUrl"));
|
|
}
|
|
|
|
return shareUrl;
|
|
});
|
|
|
|
var copiedPromise = copyTextWhenReady(shareUrlPromise);
|
|
return shareUrlPromise.then(function (shareUrl) {
|
|
return copiedPromise
|
|
.catch(function () {
|
|
return false;
|
|
})
|
|
.then(function (copied) {
|
|
return {
|
|
shareUrl: shareUrl,
|
|
copied: copied,
|
|
multiUse: !!multiUse,
|
|
};
|
|
});
|
|
});
|
|
}
|
|
);
|
|
if (!result) {
|
|
return;
|
|
}
|
|
|
|
showShareResult(result.shareUrl, result.copied, result.multiUse);
|
|
} catch (error) {
|
|
notify(extractErrorMessage(error, t("couldNotCreate")));
|
|
}
|
|
}
|
|
|
|
function pad2(value) {
|
|
return (value < 10 ? "0" : "") + value;
|
|
}
|
|
|
|
function toLocalDatetimeValue(date) {
|
|
return (
|
|
date.getFullYear() +
|
|
"-" +
|
|
pad2(date.getMonth() + 1) +
|
|
"-" +
|
|
pad2(date.getDate()) +
|
|
"T" +
|
|
pad2(date.getHours()) +
|
|
":" +
|
|
pad2(date.getMinutes())
|
|
);
|
|
}
|
|
|
|
function chooseExpiryHours(config, onChoose) {
|
|
var maxHours = clampPositiveInteger(config && config.MaxExpiryHours, 720);
|
|
var options = durationOptions
|
|
.filter(function (option) {
|
|
return option.hours <= maxHours;
|
|
})
|
|
.map(function (option) {
|
|
return {
|
|
label: durationLabel(option.hours),
|
|
hours: option.hours,
|
|
};
|
|
});
|
|
var nowMs = Date.now();
|
|
var minDate = new Date(nowMs + 5 * 60000);
|
|
var maxDate = new Date(nowMs + maxHours * 3600000);
|
|
var defaultDate = new Date(nowMs + 168 * 3600000);
|
|
if (defaultDate.getTime() > maxDate.getTime()) {
|
|
defaultDate = maxDate;
|
|
}
|
|
|
|
return openModal({
|
|
title: t("modalTitle"),
|
|
body: t("modalBody"),
|
|
options: options,
|
|
onChoose: onChoose,
|
|
cancelText: t("cancel"),
|
|
toggle: {
|
|
label: t("multiUseLabel"),
|
|
hint: buildMultiUseHint(config),
|
|
checked: !(config && config.OneUseDefault !== false),
|
|
},
|
|
datePicker: {
|
|
min: toLocalDatetimeValue(minDate),
|
|
max: toLocalDatetimeValue(maxDate),
|
|
value: toLocalDatetimeValue(defaultDate),
|
|
maxHours: maxHours,
|
|
},
|
|
});
|
|
}
|
|
|
|
function buildMultiUseHint(config) {
|
|
var hint = t("multiUseHint");
|
|
var limit = config ? parseInt(config.MaxConcurrentViewers, 10) : NaN;
|
|
if (Number.isFinite(limit) && limit > 0) {
|
|
hint += " " + t("multiUseLimit").replace("{count}", limit);
|
|
}
|
|
|
|
return hint;
|
|
}
|
|
|
|
function copyTextWhenReady(textPromise) {
|
|
if (
|
|
navigator.clipboard &&
|
|
navigator.clipboard.write &&
|
|
window.ClipboardItem &&
|
|
window.Blob
|
|
) {
|
|
try {
|
|
var blobPromise = Promise.resolve(textPromise).then(function (text) {
|
|
return new Blob([text], { type: "text/plain" });
|
|
});
|
|
return navigator.clipboard
|
|
.write([new ClipboardItem({ "text/plain": blobPromise })])
|
|
.then(function () {
|
|
return true;
|
|
})
|
|
.catch(function () {
|
|
return Promise.resolve(textPromise).then(copyText);
|
|
});
|
|
} catch (error) {
|
|
return Promise.resolve(textPromise).then(copyText);
|
|
}
|
|
}
|
|
|
|
return Promise.resolve(textPromise).then(copyText);
|
|
}
|
|
|
|
function copyText(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
return navigator.clipboard
|
|
.writeText(text)
|
|
.then(function () {
|
|
return true;
|
|
})
|
|
.catch(function () {
|
|
return fallbackCopy(text);
|
|
});
|
|
}
|
|
|
|
return Promise.resolve(fallbackCopy(text));
|
|
}
|
|
|
|
function notify(message) {
|
|
ensureShareLinksUi();
|
|
var toast = document.createElement("div");
|
|
toast.className = "sharelinks-toast";
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
window.setTimeout(function () {
|
|
toast.classList.add("is-visible");
|
|
}, 20);
|
|
window.setTimeout(function () {
|
|
toast.classList.remove("is-visible");
|
|
window.setTimeout(function () {
|
|
toast.remove();
|
|
}, 180);
|
|
}, 3600);
|
|
}
|
|
|
|
function showShareResult(shareUrl, copied, multiUse) {
|
|
ensureShareLinksUi();
|
|
var body = document.createElement("div");
|
|
var note = document.createElement("p");
|
|
note.className = "sharelinks-note";
|
|
note.textContent = copied ? t("copiedNote") : t("notCopiedNote");
|
|
body.appendChild(note);
|
|
|
|
if (multiUse) {
|
|
var multiUseNote = document.createElement("p");
|
|
multiUseNote.className = "sharelinks-note";
|
|
multiUseNote.textContent = t("resultMultiUseNote");
|
|
body.appendChild(multiUseNote);
|
|
}
|
|
|
|
var urlBox = document.createElement("textarea");
|
|
urlBox.className = "sharelinks-url";
|
|
urlBox.readOnly = true;
|
|
urlBox.value = shareUrl;
|
|
body.appendChild(urlBox);
|
|
|
|
openModalElement({
|
|
title: copied ? t("resultCopiedTitle") : t("resultCreatedTitle"),
|
|
bodyElement: body,
|
|
actions: [
|
|
{
|
|
label: t("copy"),
|
|
primary: true,
|
|
handler: function () {
|
|
return copyText(shareUrl).then(function (ok) {
|
|
if (ok) {
|
|
notify(t("toastCopied"));
|
|
} else {
|
|
urlBox.focus();
|
|
urlBox.select();
|
|
notify(t("toastManual"));
|
|
}
|
|
return ok;
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: t("done"),
|
|
close: true,
|
|
},
|
|
],
|
|
onOpen: function () {
|
|
urlBox.focus();
|
|
urlBox.select();
|
|
},
|
|
});
|
|
}
|
|
|
|
function openModal(settings) {
|
|
var body = document.createElement("div");
|
|
var text = document.createElement("p");
|
|
text.className = "sharelinks-note";
|
|
text.textContent = settings.body;
|
|
body.appendChild(text);
|
|
|
|
var grid = document.createElement("div");
|
|
grid.className = "sharelinks-duration-grid";
|
|
body.appendChild(grid);
|
|
|
|
var dateInput = null;
|
|
if (settings.datePicker) {
|
|
var dateRow = document.createElement("div");
|
|
dateRow.className = "sharelinks-date-row";
|
|
|
|
var dateLabel = document.createElement("label");
|
|
dateLabel.className = "sharelinks-date-label";
|
|
dateLabel.textContent = t("dateLabel");
|
|
|
|
dateInput = document.createElement("input");
|
|
dateInput.type = "datetime-local";
|
|
dateInput.className = "sharelinks-date-input";
|
|
dateInput.min = settings.datePicker.min;
|
|
dateInput.max = settings.datePicker.max;
|
|
dateInput.value = settings.datePicker.value;
|
|
|
|
dateLabel.setAttribute("for", "sharelinksExpiryDate");
|
|
dateInput.id = "sharelinksExpiryDate";
|
|
|
|
dateRow.appendChild(dateLabel);
|
|
dateRow.appendChild(dateInput);
|
|
body.appendChild(dateRow);
|
|
}
|
|
|
|
var toggleInput = null;
|
|
if (settings.toggle) {
|
|
var toggleRow = document.createElement("label");
|
|
toggleRow.className = "sharelinks-toggle-row";
|
|
|
|
toggleInput = document.createElement("input");
|
|
toggleInput.type = "checkbox";
|
|
toggleInput.className = "sharelinks-toggle-input";
|
|
toggleInput.checked = !!settings.toggle.checked;
|
|
|
|
var toggleText = document.createElement("span");
|
|
toggleText.className = "sharelinks-toggle-text";
|
|
|
|
var toggleLabel = document.createElement("span");
|
|
toggleLabel.className = "sharelinks-toggle-label";
|
|
toggleLabel.textContent = settings.toggle.label;
|
|
toggleText.appendChild(toggleLabel);
|
|
|
|
if (settings.toggle.hint) {
|
|
var toggleHint = document.createElement("span");
|
|
toggleHint.className = "sharelinks-toggle-hint";
|
|
toggleHint.textContent = settings.toggle.hint;
|
|
toggleText.appendChild(toggleHint);
|
|
}
|
|
|
|
toggleRow.appendChild(toggleInput);
|
|
toggleRow.appendChild(toggleText);
|
|
body.appendChild(toggleRow);
|
|
}
|
|
|
|
function toggleChecked() {
|
|
return !!(toggleInput && toggleInput.checked);
|
|
}
|
|
|
|
return new Promise(function (resolve) {
|
|
var modal;
|
|
var actions = [];
|
|
|
|
if (settings.datePicker) {
|
|
actions.push({
|
|
label: t("create"),
|
|
primary: true,
|
|
handler: function () {
|
|
var raw = dateInput && dateInput.value;
|
|
if (!raw) {
|
|
notify(t("pickDateFirst"));
|
|
return;
|
|
}
|
|
|
|
var target = new Date(raw);
|
|
if (isNaN(target.getTime())) {
|
|
notify(t("dateInvalid"));
|
|
return;
|
|
}
|
|
|
|
var hours = Math.ceil((target.getTime() - Date.now()) / 3600000);
|
|
if (hours < 1) {
|
|
notify(t("pickFuture"));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
settings.datePicker.maxHours &&
|
|
hours > settings.datePicker.maxHours
|
|
) {
|
|
hours = settings.datePicker.maxHours;
|
|
}
|
|
|
|
if (modal) {
|
|
modal.close();
|
|
}
|
|
resolve(
|
|
settings.onChoose
|
|
? settings.onChoose(hours, toggleChecked())
|
|
: hours
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
actions.push({
|
|
label: settings.cancelText || t("cancel"),
|
|
close: true,
|
|
handler: function () {
|
|
resolve(null);
|
|
},
|
|
});
|
|
|
|
modal = openModalElement({
|
|
title: settings.title,
|
|
bodyElement: body,
|
|
actions: actions,
|
|
onDismiss: function () {
|
|
resolve(null);
|
|
},
|
|
});
|
|
|
|
settings.options.forEach(function (option) {
|
|
var button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "sharelinks-duration-button";
|
|
button.textContent = option.label;
|
|
|
|
button.addEventListener("click", function () {
|
|
modal.close();
|
|
if (settings.onChoose) {
|
|
resolve(settings.onChoose(option.hours, toggleChecked()));
|
|
} else {
|
|
resolve(option.hours);
|
|
}
|
|
});
|
|
grid.appendChild(button);
|
|
});
|
|
});
|
|
}
|
|
|
|
function openModalElement(settings) {
|
|
ensureShareLinksUi();
|
|
var closed = false;
|
|
var overlay = document.createElement("div");
|
|
overlay.className = "sharelinks-overlay";
|
|
overlay.setAttribute("role", "presentation");
|
|
|
|
var dialog = document.createElement("div");
|
|
dialog.className = "sharelinks-dialog";
|
|
dialog.setAttribute("role", "dialog");
|
|
dialog.setAttribute("aria-modal", "true");
|
|
dialog.setAttribute("aria-label", settings.title);
|
|
overlay.appendChild(dialog);
|
|
|
|
var title = document.createElement("h3");
|
|
title.className = "sharelinks-title";
|
|
title.textContent = settings.title;
|
|
dialog.appendChild(title);
|
|
|
|
dialog.appendChild(settings.bodyElement);
|
|
|
|
var actions = document.createElement("div");
|
|
actions.className = "sharelinks-actions";
|
|
dialog.appendChild(actions);
|
|
|
|
function close() {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
overlay.classList.remove("is-visible");
|
|
window.setTimeout(function () {
|
|
overlay.remove();
|
|
}, 160);
|
|
}
|
|
|
|
(settings.actions || []).forEach(function (action) {
|
|
var button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = action.primary
|
|
? "sharelinks-action primary"
|
|
: "sharelinks-action";
|
|
button.textContent = action.label;
|
|
button.addEventListener("click", function () {
|
|
var result = action.handler ? action.handler() : null;
|
|
Promise.resolve(result).finally(function () {
|
|
if (action.close) {
|
|
close();
|
|
}
|
|
});
|
|
});
|
|
actions.appendChild(button);
|
|
});
|
|
|
|
overlay.addEventListener("click", function (event) {
|
|
if (event.target === overlay) {
|
|
close();
|
|
if (settings.onDismiss) {
|
|
settings.onDismiss();
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener(
|
|
"keydown",
|
|
function onKeyDown(event) {
|
|
if (event.key === "Escape" && !closed) {
|
|
document.removeEventListener("keydown", onKeyDown, true);
|
|
close();
|
|
if (settings.onDismiss) {
|
|
settings.onDismiss();
|
|
}
|
|
}
|
|
},
|
|
true
|
|
);
|
|
|
|
document.body.appendChild(overlay);
|
|
window.setTimeout(function () {
|
|
overlay.classList.add("is-visible");
|
|
var firstButton = overlay.querySelector("button:not([disabled])");
|
|
if (firstButton) {
|
|
firstButton.focus();
|
|
}
|
|
if (settings.onOpen) {
|
|
settings.onOpen();
|
|
}
|
|
}, 20);
|
|
|
|
return { close: close, element: overlay };
|
|
}
|
|
|
|
function ensureShareLinksUi() {
|
|
if (document.getElementById("ShareLinksUiStyle")) {
|
|
return;
|
|
}
|
|
|
|
var style = document.createElement("style");
|
|
style.id = "ShareLinksUiStyle";
|
|
style.textContent = [
|
|
".sharelinks-overlay{position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,.58);display:grid;place-items:center;padding:24px;opacity:0;transition:opacity .16s ease;}",
|
|
".sharelinks-overlay.is-visible{opacity:1;}",
|
|
".sharelinks-dialog{width:min(520px,100%);background:var(--background-color,#202020);color:var(--text-color,#fff);box-shadow:0 18px 60px rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:22px;}",
|
|
".sharelinks-title{font-size:1.25rem;line-height:1.3;margin:0 0 14px;font-weight:600;}",
|
|
".sharelinks-note{margin:0 0 16px;color:var(--text-secondary-color,#cfcfcf);line-height:1.45;}",
|
|
".sharelinks-duration-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:4px;}",
|
|
".sharelinks-duration-button,.sharelinks-action{border:0;border-radius:6px;background:rgba(255,255,255,.12);color:inherit;padding:10px 12px;min-height:42px;cursor:pointer;font:inherit;}",
|
|
".sharelinks-duration-button:hover:not(:disabled),.sharelinks-action:hover{background:rgba(255,255,255,.18);}",
|
|
".sharelinks-duration-button:disabled{opacity:.35;cursor:not-allowed;}",
|
|
".sharelinks-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px;}",
|
|
".sharelinks-action.primary{background:var(--theme-primary-color,#00a4dc);color:#fff;}",
|
|
".sharelinks-url{width:100%;min-height:88px;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px;font:inherit;resize:vertical;}",
|
|
".sharelinks-date-row{margin-top:18px;display:flex;flex-direction:column;gap:8px;}",
|
|
".sharelinks-date-label{color:var(--text-secondary-color,#cfcfcf);font-size:.92rem;line-height:1.4;}",
|
|
".sharelinks-date-input{width:100%;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px 12px;min-height:42px;font:inherit;color-scheme:dark;}",
|
|
".sharelinks-toggle-row{display:flex;align-items:flex-start;gap:10px;margin-top:18px;padding-top:16px;border-top:1px solid rgba(255,255,255,.12);cursor:pointer;}",
|
|
".sharelinks-toggle-input{margin:2px 0 0;width:18px;height:18px;flex:0 0 auto;accent-color:var(--theme-primary-color,#00a4dc);cursor:pointer;}",
|
|
".sharelinks-toggle-text{display:flex;flex-direction:column;gap:4px;}",
|
|
".sharelinks-toggle-label{line-height:1.35;}",
|
|
".sharelinks-toggle-hint{color:var(--text-secondary-color,#cfcfcf);font-size:.88rem;line-height:1.4;}",
|
|
".sharelinks-toast{position:fixed;left:24px;bottom:24px;z-index:1000000;max-width:min(460px,calc(100vw - 48px));background:rgba(24,24,24,.96);color:#fff;border:1px solid rgba(255,255,255,.14);border-radius:6px;padding:11px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;}",
|
|
".sharelinks-toast.is-visible{opacity:1;transform:translateY(0);}",
|
|
"@media (max-width:520px){.sharelinks-duration-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.sharelinks-dialog{padding:18px;}.sharelinks-actions{justify-content:stretch;}.sharelinks-action{flex:1;}}",
|
|
].join("");
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function fallbackCopy(text) {
|
|
var textarea = document.createElement("textarea");
|
|
textarea.value = text;
|
|
textarea.setAttribute("readonly", "readonly");
|
|
textarea.style.position = "fixed";
|
|
textarea.style.top = "-1000px";
|
|
textarea.style.left = "-1000px";
|
|
document.body.appendChild(textarea);
|
|
textarea.focus();
|
|
textarea.select();
|
|
var copied = false;
|
|
try {
|
|
copied = document.execCommand("copy");
|
|
} catch (error) {
|
|
copied = false;
|
|
}
|
|
textarea.remove();
|
|
return copied;
|
|
}
|
|
|
|
function clampPositiveInteger(value, fallback) {
|
|
var parsed = parseInt(value, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function extractErrorMessage(error, fallback) {
|
|
if (!error) {
|
|
return fallback;
|
|
}
|
|
|
|
if (typeof error === "string") {
|
|
return error;
|
|
}
|
|
|
|
if (error.responseJSON && error.responseJSON.error) {
|
|
return error.responseJSON.error;
|
|
}
|
|
|
|
if (error.responseText) {
|
|
return error.responseText;
|
|
}
|
|
|
|
if (error.message) {
|
|
return error.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
start();
|
|
})();
|