Skip to content

Commit e7aea56

Browse files
Add performer-poster-backdrop plugin (#648)
1 parent 9374193 commit e7aea56

File tree

5 files changed

+337
-0
lines changed

5 files changed

+337
-0
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Performer Poster Backdrop (Stash UI Plugin)
2+
3+
Adds a blurred poster-style backdrop behind performer headers using the performer’s poster image.
4+
5+
![Performer Poster Backdrop example](https://raw.githubusercontent.com/worryaboutstuff/performer-poster-backdrop/main/assets/performer-poster-backdrop-example.png)
6+
7+
8+
## Features
9+
10+
- Applies only to **Performer pages**
11+
- Uses the performer’s **poster image** as a background layer
12+
- Adjustable:
13+
- Opacity
14+
- Blur strength
15+
- Vertical image alignment
16+
- Supports **per-performer Y-offset overrides**
17+
- Blank settings automatically fall back to defaults
18+
19+
## Installation
20+
21+
1. Copy the following files into your Stash UI plugins directory:
22+
- `performer-poster-backdrop.yml`
23+
- `performer-poster-backdrop.js`
24+
- `performer-poster-backdrop.css`
25+
26+
2. In Stash, go to **Settings → Plugins**
27+
3. Find **Performer Poster Backdrop**
28+
4. Adjust settings if desired and click **Confirm**
29+
5. Refresh a performer page
30+
31+
## Settings
32+
33+
### Backdrop opacity
34+
Controls how visible the backdrop is.
35+
36+
- Range: `0``1`
37+
- Default: `1`
38+
- Examples:
39+
- `0.7` → subtle
40+
- `0.5` → very soft
41+
- `0` → invisible
42+
43+
Leaving this field blank uses the default.
44+
45+
### Backdrop blur
46+
Controls how blurred the backdrop appears (in pixels).
47+
48+
- Default: `10`
49+
- Examples:
50+
- `5` → light blur
51+
- `15` → strong blur
52+
- `0` → no blur
53+
54+
Leaving this field blank uses the default.
55+
56+
### Default Y offset
57+
Controls the vertical alignment of the backdrop image.
58+
59+
- Range: `0``100`
60+
- Default: `20`
61+
- Meaning:
62+
- `0` → favor top of image
63+
- `50` → center
64+
- `100` → favor bottom
65+
66+
Leaving this field blank uses the default.
67+
68+
## Per-performer Y overrides
69+
70+
Use this when a specific performer’s poster needs different vertical positioning.
71+
72+
Enter overrides as a **comma-separated list** in a single text field.
73+
74+
### Format
75+
76+
`PERFORMER_ID:OFFSET`
77+
78+
### Example
79+
80+
`142:35, 219:20, 501:50`
81+
82+
83+
84+
Accepted separators:
85+
- `:` (recommended)
86+
- `=`
87+
- `-`
88+
89+
Whitespace is ignored.
577 KB
Loading
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
.pb-hero {
2+
position: absolute;
3+
inset: 2px;
4+
5+
background-size: cover;
6+
background-position: center var(--pb-y, 20%);
7+
8+
opacity: var(--pb-opacity, 1);
9+
filter: blur(var(--pb-blur, 10px));
10+
transform: scale(1.2);
11+
z-index: 0;
12+
pointer-events: none;
13+
}
14+
15+
.pb-hero::after {
16+
content: "";
17+
position: absolute;
18+
inset: 0;
19+
20+
background:
21+
radial-gradient(
22+
ellipse at center,
23+
rgba(0,0,0,0.0) 0%,
24+
rgba(0,0,0,0.35) 55%,
25+
rgba(0,0,0,0.75) 100%
26+
),
27+
linear-gradient(
28+
to bottom,
29+
rgba(0,0,0,0.15),
30+
rgba(0,0,0,0.75)
31+
);
32+
33+
box-shadow:
34+
inset 0 0 0 1px rgba(255,255,255,0.025),
35+
inset 0 14px 28px rgba(0,0,0,0.55),
36+
inset 0 -22px 36px rgba(0,0,0,0.85);
37+
}
38+
39+
/* IMPORTANT: only the REAL header */
40+
#performer-page .detail-header.full-width {
41+
position: relative;
42+
overflow: hidden;
43+
}
44+
45+
/* Lift content above banner ONLY in the REAL header */
46+
#performer-page .detail-header.full-width > *:not(.pb-hero) {
47+
position: relative;
48+
z-index: 1;
49+
}
50+
51+
/* Hide while editing performer */
52+
#performer-page .detail-header.edit.full-width .pb-hero {
53+
display: none;
54+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
(function () {
2+
const HERO_CLASS = "pb-hero";
3+
const PLUGIN_ID = "performer-poster-backdrop";
4+
5+
// HARD DEFAULTS (used when fields are blank)
6+
const DEFAULTS = {
7+
opacity: 1, // 0..1
8+
blur: 10, // px
9+
y: 20, // %
10+
};
11+
12+
let opacity = DEFAULTS.opacity;
13+
let blur = DEFAULTS.blur;
14+
let defaultY = DEFAULTS.y;
15+
let overrides = new Map();
16+
17+
let lastSettingsFetch = 0;
18+
19+
const isBlank = (v) =>
20+
v === null || v === undefined || String(v).trim() === "";
21+
22+
function isPerformerRoute() {
23+
return /^\/performers\/\d+/.test(location.pathname);
24+
}
25+
26+
function getPerformerIdFromPath() {
27+
const m = location.pathname.match(/^\/performers\/(\d+)/);
28+
return m ? String(Number(m[1])) : null;
29+
}
30+
31+
function getHeader() {
32+
return document.querySelector("#performer-page .detail-header.full-width");
33+
}
34+
35+
function getStickyHeader() {
36+
return document.querySelector("#performer-page .sticky.detail-header");
37+
}
38+
39+
function getPosterImg() {
40+
return (
41+
document.querySelector("#performer-page .detail-header-image img.performer") ||
42+
document.querySelector("#performer-page img.performer")
43+
);
44+
}
45+
46+
function clamp(n, min, max, fallback) {
47+
const x = Number(n);
48+
if (!Number.isFinite(x)) return fallback;
49+
return Math.min(max, Math.max(min, x));
50+
}
51+
52+
// COMMA-SEPARATED overrides: "142:35, 219:20"
53+
function parseOverrides(text) {
54+
const map = new Map();
55+
if (isBlank(text)) return map;
56+
57+
text.split(",").forEach((chunk) => {
58+
const s = chunk.trim();
59+
if (!s) return;
60+
61+
// Accept 142:35, 142-35, 142=35
62+
const m = s.match(/^(\d+)\s*[:=-]\s*(\d{1,3})$/);
63+
if (!m) return;
64+
65+
const id = String(Number(m[1]));
66+
const pct = clamp(m[2], 0, 100, DEFAULTS.y);
67+
map.set(id, pct);
68+
});
69+
70+
return map;
71+
}
72+
73+
function removeHero(el) {
74+
el?.querySelector("." + HERO_CLASS)?.remove();
75+
}
76+
77+
function upsertHero(header, url) {
78+
let hero = header.querySelector("." + HERO_CLASS);
79+
if (!hero) {
80+
hero = document.createElement("div");
81+
hero.className = HERO_CLASS;
82+
header.prepend(hero);
83+
}
84+
hero.style.backgroundImage = `url("${url}")`;
85+
return hero;
86+
}
87+
88+
function apply(hero) {
89+
hero.style.setProperty("--pb-opacity", opacity);
90+
hero.style.setProperty("--pb-blur", `${blur}px`);
91+
92+
const pid = getPerformerIdFromPath();
93+
const y = pid && overrides.has(pid) ? overrides.get(pid) : defaultY;
94+
hero.style.setProperty("--pb-y", `${y}%`);
95+
}
96+
97+
async function refreshSettings() {
98+
if (Date.now() - lastSettingsFetch < 5000) return;
99+
lastSettingsFetch = Date.now();
100+
101+
try {
102+
const res = await fetch("/graphql", {
103+
method: "POST",
104+
headers: { "Content-Type": "application/json" },
105+
body: JSON.stringify({
106+
query: `{ configuration { plugins } }`,
107+
}),
108+
});
109+
110+
if (!res.ok) return;
111+
112+
const cfg = (await res.json())
113+
?.data?.configuration?.plugins?.[PLUGIN_ID];
114+
115+
if (!cfg) return;
116+
117+
opacity = isBlank(cfg.opacity)
118+
? DEFAULTS.opacity
119+
: clamp(cfg.opacity, 0, 1, DEFAULTS.opacity);
120+
121+
blur = isBlank(cfg.blur)
122+
? DEFAULTS.blur
123+
: clamp(cfg.blur, 0, 100, DEFAULTS.blur);
124+
125+
defaultY = isBlank(cfg.defaultYOffset)
126+
? DEFAULTS.y
127+
: clamp(cfg.defaultYOffset, 0, 100, DEFAULTS.y);
128+
129+
overrides = isBlank(cfg.perPerformerOffsets)
130+
? new Map()
131+
: parseOverrides(cfg.perPerformerOffsets);
132+
133+
const hero = getHeader()?.querySelector("." + HERO_CLASS);
134+
if (hero) apply(hero);
135+
} catch {
136+
// silent fail — plugin should never break UI
137+
}
138+
}
139+
140+
function run() {
141+
removeHero(getStickyHeader());
142+
143+
const header = getHeader();
144+
if (!header || !isPerformerRoute()) return removeHero(header);
145+
146+
const img = getPosterImg();
147+
if (!img) return;
148+
149+
const hero = upsertHero(header, img.currentSrc || img.src);
150+
apply(hero);
151+
refreshSettings();
152+
}
153+
154+
new MutationObserver(run).observe(document.body, {
155+
childList: true,
156+
subtree: true,
157+
});
158+
159+
["load", "resize", "popstate"].forEach((e) =>
160+
window.addEventListener(e, () => setTimeout(run, 50))
161+
);
162+
163+
setTimeout(run, 50);
164+
})();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Performer Poster Backdrop
2+
description: Adds a blurred poster backdrop to performer pages.
3+
version: 1.0.3
4+
5+
ui:
6+
javascript:
7+
- performer-poster-backdrop.js
8+
css:
9+
- performer-poster-backdrop.css
10+
11+
settings:
12+
opacity:
13+
displayName: Backdrop opacity
14+
description: "0–1 (leave blank for default: 1)"
15+
type: STRING
16+
17+
blur:
18+
displayName: Backdrop blur
19+
description: "Pixels (leave blank for default: 10)"
20+
type: STRING
21+
22+
defaultYOffset:
23+
displayName: Default Y offset
24+
description: "Background position % (leave blank for default: 20)"
25+
type: STRING
26+
27+
perPerformerOffsets:
28+
displayName: Per-performer Y overrides
29+
description: "Comma seperated: performerId:percent (blank = none)"
30+
type: STRING

0 commit comments

Comments
 (0)