diff --git a/plugins/performer-poster-backdrop/README.md b/plugins/performer-poster-backdrop/README.md new file mode 100644 index 00000000..5d826cc3 --- /dev/null +++ b/plugins/performer-poster-backdrop/README.md @@ -0,0 +1,89 @@ +# Performer Poster Backdrop (Stash UI Plugin) + +Adds a blurred poster-style backdrop behind performer headers using the performer’s poster image. + +![Performer Poster Backdrop example](https://raw.githubusercontent.com/worryaboutstuff/performer-poster-backdrop/main/assets/performer-poster-backdrop-example.png) + + +## Features + +- Applies only to **Performer pages** +- Uses the performer’s **poster image** as a background layer +- Adjustable: + - Opacity + - Blur strength + - Vertical image alignment +- Supports **per-performer Y-offset overrides** +- Blank settings automatically fall back to defaults + +## Installation + +1. Copy the following files into your Stash UI plugins directory: + - `performer-poster-backdrop.yml` + - `performer-poster-backdrop.js` + - `performer-poster-backdrop.css` + +2. In Stash, go to **Settings → Plugins** +3. Find **Performer Poster Backdrop** +4. Adjust settings if desired and click **Confirm** +5. Refresh a performer page + +## Settings + +### Backdrop opacity +Controls how visible the backdrop is. + +- Range: `0`–`1` +- Default: `1` +- Examples: + - `0.7` → subtle + - `0.5` → very soft + - `0` → invisible + +Leaving this field blank uses the default. + +### Backdrop blur +Controls how blurred the backdrop appears (in pixels). + +- Default: `10` +- Examples: + - `5` → light blur + - `15` → strong blur + - `0` → no blur + +Leaving this field blank uses the default. + +### Default Y offset +Controls the vertical alignment of the backdrop image. + +- Range: `0`–`100` +- Default: `20` +- Meaning: + - `0` → favor top of image + - `50` → center + - `100` → favor bottom + +Leaving this field blank uses the default. + +## Per-performer Y overrides + +Use this when a specific performer’s poster needs different vertical positioning. + +Enter overrides as a **comma-separated list** in a single text field. + +### Format + +`PERFORMER_ID:OFFSET` + +### Example + +`142:35, 219:20, 501:50` + + + +Accepted separators: +- `:` (recommended) +- `=` +- `-` + +Whitespace is ignored. diff --git a/plugins/performer-poster-backdrop/assets/performer-poster-backdrop-example.png b/plugins/performer-poster-backdrop/assets/performer-poster-backdrop-example.png new file mode 100644 index 00000000..bee2719c Binary files /dev/null and b/plugins/performer-poster-backdrop/assets/performer-poster-backdrop-example.png differ diff --git a/plugins/performer-poster-backdrop/performer-poster-backdrop.css b/plugins/performer-poster-backdrop/performer-poster-backdrop.css new file mode 100644 index 00000000..f2d5f99d --- /dev/null +++ b/plugins/performer-poster-backdrop/performer-poster-backdrop.css @@ -0,0 +1,54 @@ +.pb-hero { + position: absolute; + inset: 2px; + + background-size: cover; + background-position: center var(--pb-y, 20%); + + opacity: var(--pb-opacity, 1); + filter: blur(var(--pb-blur, 10px)); + transform: scale(1.2); + z-index: 0; + pointer-events: none; +} + +.pb-hero::after { + content: ""; + position: absolute; + inset: 0; + + background: + radial-gradient( + ellipse at center, + rgba(0,0,0,0.0) 0%, + rgba(0,0,0,0.35) 55%, + rgba(0,0,0,0.75) 100% + ), + linear-gradient( + to bottom, + rgba(0,0,0,0.15), + rgba(0,0,0,0.75) + ); + + box-shadow: + inset 0 0 0 1px rgba(255,255,255,0.025), + inset 0 14px 28px rgba(0,0,0,0.55), + inset 0 -22px 36px rgba(0,0,0,0.85); +} + +/* IMPORTANT: only the REAL header */ +#performer-page .detail-header.full-width { + position: relative; + overflow: hidden; +} + +/* Lift content above banner ONLY in the REAL header */ +#performer-page .detail-header.full-width > *:not(.pb-hero) { + position: relative; + z-index: 1; +} + +/* Hide while editing performer */ +#performer-page .detail-header.edit.full-width .pb-hero { + display: none; +} diff --git a/plugins/performer-poster-backdrop/performer-poster-backdrop.js b/plugins/performer-poster-backdrop/performer-poster-backdrop.js new file mode 100644 index 00000000..322532de --- /dev/null +++ b/plugins/performer-poster-backdrop/performer-poster-backdrop.js @@ -0,0 +1,164 @@ +(function () { + const HERO_CLASS = "pb-hero"; + const PLUGIN_ID = "performer-poster-backdrop"; + + // HARD DEFAULTS (used when fields are blank) + const DEFAULTS = { + opacity: 1, // 0..1 + blur: 10, // px + y: 20, // % + }; + + let opacity = DEFAULTS.opacity; + let blur = DEFAULTS.blur; + let defaultY = DEFAULTS.y; + let overrides = new Map(); + + let lastSettingsFetch = 0; + + const isBlank = (v) => + v === null || v === undefined || String(v).trim() === ""; + + function isPerformerRoute() { + return /^\/performers\/\d+/.test(location.pathname); + } + + function getPerformerIdFromPath() { + const m = location.pathname.match(/^\/performers\/(\d+)/); + return m ? String(Number(m[1])) : null; + } + + function getHeader() { + return document.querySelector("#performer-page .detail-header.full-width"); + } + + function getStickyHeader() { + return document.querySelector("#performer-page .sticky.detail-header"); + } + + function getPosterImg() { + return ( + document.querySelector("#performer-page .detail-header-image img.performer") || + document.querySelector("#performer-page img.performer") + ); + } + + function clamp(n, min, max, fallback) { + const x = Number(n); + if (!Number.isFinite(x)) return fallback; + return Math.min(max, Math.max(min, x)); + } + + // COMMA-SEPARATED overrides: "142:35, 219:20" + function parseOverrides(text) { + const map = new Map(); + if (isBlank(text)) return map; + + text.split(",").forEach((chunk) => { + const s = chunk.trim(); + if (!s) return; + + // Accept 142:35, 142-35, 142=35 + const m = s.match(/^(\d+)\s*[:=-]\s*(\d{1,3})$/); + if (!m) return; + + const id = String(Number(m[1])); + const pct = clamp(m[2], 0, 100, DEFAULTS.y); + map.set(id, pct); + }); + + return map; + } + + function removeHero(el) { + el?.querySelector("." + HERO_CLASS)?.remove(); + } + + function upsertHero(header, url) { + let hero = header.querySelector("." + HERO_CLASS); + if (!hero) { + hero = document.createElement("div"); + hero.className = HERO_CLASS; + header.prepend(hero); + } + hero.style.backgroundImage = `url("${url}")`; + return hero; + } + + function apply(hero) { + hero.style.setProperty("--pb-opacity", opacity); + hero.style.setProperty("--pb-blur", `${blur}px`); + + const pid = getPerformerIdFromPath(); + const y = pid && overrides.has(pid) ? overrides.get(pid) : defaultY; + hero.style.setProperty("--pb-y", `${y}%`); + } + + async function refreshSettings() { + if (Date.now() - lastSettingsFetch < 5000) return; + lastSettingsFetch = Date.now(); + + try { + const res = await fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `{ configuration { plugins } }`, + }), + }); + + if (!res.ok) return; + + const cfg = (await res.json()) + ?.data?.configuration?.plugins?.[PLUGIN_ID]; + + if (!cfg) return; + + opacity = isBlank(cfg.opacity) + ? DEFAULTS.opacity + : clamp(cfg.opacity, 0, 1, DEFAULTS.opacity); + + blur = isBlank(cfg.blur) + ? DEFAULTS.blur + : clamp(cfg.blur, 0, 100, DEFAULTS.blur); + + defaultY = isBlank(cfg.defaultYOffset) + ? DEFAULTS.y + : clamp(cfg.defaultYOffset, 0, 100, DEFAULTS.y); + + overrides = isBlank(cfg.perPerformerOffsets) + ? new Map() + : parseOverrides(cfg.perPerformerOffsets); + + const hero = getHeader()?.querySelector("." + HERO_CLASS); + if (hero) apply(hero); + } catch { + // silent fail — plugin should never break UI + } + } + + function run() { + removeHero(getStickyHeader()); + + const header = getHeader(); + if (!header || !isPerformerRoute()) return removeHero(header); + + const img = getPosterImg(); + if (!img) return; + + const hero = upsertHero(header, img.currentSrc || img.src); + apply(hero); + refreshSettings(); + } + + new MutationObserver(run).observe(document.body, { + childList: true, + subtree: true, + }); + + ["load", "resize", "popstate"].forEach((e) => + window.addEventListener(e, () => setTimeout(run, 50)) + ); + + setTimeout(run, 50); +})(); diff --git a/plugins/performer-poster-backdrop/performer-poster-backdrop.yml b/plugins/performer-poster-backdrop/performer-poster-backdrop.yml new file mode 100644 index 00000000..fd129053 --- /dev/null +++ b/plugins/performer-poster-backdrop/performer-poster-backdrop.yml @@ -0,0 +1,30 @@ +name: Performer Poster Backdrop +description: Adds a blurred poster backdrop to performer pages. +version: 1.0.3 + +ui: + javascript: + - performer-poster-backdrop.js + css: + - performer-poster-backdrop.css + +settings: + opacity: + displayName: Backdrop opacity + description: "0–1 (leave blank for default: 1)" + type: STRING + + blur: + displayName: Backdrop blur + description: "Pixels (leave blank for default: 10)" + type: STRING + + defaultYOffset: + displayName: Default Y offset + description: "Background position % (leave blank for default: 20)" + type: STRING + + perPerformerOffsets: + displayName: Per-performer Y overrides + description: "Comma seperated: performerId:percent (blank = none)" + type: STRING