Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions plugins/performer-poster-backdrop/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
54 changes: 54 additions & 0 deletions plugins/performer-poster-backdrop/performer-poster-backdrop.css
Original file line number Diff line number Diff line change
@@ -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;
}
164 changes: 164 additions & 0 deletions plugins/performer-poster-backdrop/performer-poster-backdrop.js
Original file line number Diff line number Diff line change
@@ -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);
})();
30 changes: 30 additions & 0 deletions plugins/performer-poster-backdrop/performer-poster-backdrop.yml
Original file line number Diff line number Diff line change
@@ -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