[mobileWallLayout] Add play-on-visibility and ordered loading (#704)

* [mobileWallLayout] Add play-on-visibility and ordered loading

Adds two mobile-first behaviors to the existing layout plugin and
rebrands the display name to "scrollFeed":

- IntersectionObserver gates <video> play/pause on 10% visibility.
  Caps concurrent playbacks to ~3, well under iOS Safari's
  ~20-simultaneous-<video> ceiling that the unchanged Stash wall
  hits on a 20-card page.

- A DOM-ordered load queue cancels the parallel-fetch storm Stash
  kicks off when all wall cards mount at once. Top clips get
  uncontested bandwidth first, and a 500ms timeout advance ensures
  the full page is in-flight within ~5s so degrading reception
  doesn't strand the bottom of the page with zero bytes.

The plugin's internal id stays mobileWallLayout so existing
installs upgrade cleanly. The whole file is wrapped in an IIFE
to avoid top-level name collisions with other plugins loaded
into the same document.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Restore discourse thread URL in plugin YAML and README

The scrollFeed rebrand inadvertently repointed the YAML url: field to a
GitHub tree URL and dropped the discourse link from the top of the README.
Every other plugin in this repo uses the discourse thread as its url: value
(it's what the Stash plugins panel surfaces as the plugin's support
link), so restoring the original discourse URL here keeps the convention
and keeps existing users linked to the plugin's discussion thread.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
speckofthecosmos
2026-04-22 04:13:28 -04:00
committed by GitHub
parent 589983b20f
commit afc8208bf5
3 changed files with 374 additions and 99 deletions

View File

@@ -1,29 +1,70 @@
# Mobile Wall Layout
# scrollFeed (mobileWallLayout)
https://discourse.stashapp.cc/t/mobile-wall-layout/6160
Discussion: https://discourse.stashapp.cc/t/mobile-wall-layout/6160
Makes the wall-mode gallery render as a single full-width column on mobile
devices, on the **Markers** (`/scenes/markers`) and **Images** (`/images`) pages.
Turns Stash's **Markers** (`/scenes/markers`) and **Images** (`/images`) wall
into a scrollable mobile feed — full-width single-column layout, video
play-on-visibility, and DOM-ordered loading that keeps the feed watchable
over cellular and degrading connections.
By default, Stash's wall mode uses `react-photo-gallery`, which calculates
`position: absolute` offsets for a multi-column brick layout. On small screens
this produces items that are too small to comfortably tap and browse. This
plugin overrides those offsets so each item spans the full width of the screen,
making marker previews and images easy to scroll through on a phone.
The plugin is published under the filename `mobileWallLayout` (and that's
still the internal ID, so existing installs upgrade cleanly). The display
name it presents in Stash's Plugins panel is `scrollFeed`.
## Behaviour
## What it does
- Applies only on **touch-screen devices** (`pointer: coarse`) — correctly
targets phones and tablets without triggering on narrow desktop browser windows.
- Activates and deactivates automatically as you navigate between pages.
- Has no effect on desktop or mouse-driven viewports.
1. **Full-width single-column layout on touch devices.** By default, Stash's
wall uses `react-photo-gallery`, which calculates `position: absolute`
offsets for a multi-column brick layout. On phones those offsets produce
items that are too small to tap through comfortably. The plugin injects
a `<style>` tag wrapped in a `@media (pointer: coarse)` query to override
them. Touchscreens get the mobile feed; desktop and mouse-driven
viewports are untouched.
## Implementation note
2. **Play-on-visibility.** Stash marks marker previews with `autoPlay`, so
a 20-card page can fire 20 simultaneous playbacks — iOS Safari bogs down
past its ~20-`<video>` decoder ceiling. An `IntersectionObserver` plays
each clip at 10% visibility and pauses it when it leaves the viewport.
In practice 23 clips play concurrently, which is what you want when
scrolling a feed.
The fix injects a `<style>` tag with `!important` rules wrapped in a
`@media (pointer: coarse)` query, rather than setting inline styles via
JavaScript or checking `window.innerWidth` at runtime. This is necessary
because `react-photo-gallery` continuously recalculates and re-applies its own
inline styles; a CSS rule with `!important` wins unconditionally regardless of
render timing. Using `pointer: coarse` instead of a pixel-width threshold
prevents the fix from activating on narrow desktop windows.
3. **DOM-ordered loading.** When the wall mounts, React starts parallel
fetches for every video on the page. On cellular that splits the uplink
20 ways and no video is playable for a long time. The plugin cancels
those fetches, pushes the videos onto an ordered queue, and re-issues
them top-down — 2 concurrent at a time, advancing on `canplay` or a
500ms fallback. The top clip is playable quickly, and the entire page
is in-flight within ~5 seconds, so moving into a weaker-signal area
doesn't leave the bottom of the page with zero bytes.
## Target pages
Active only on `/scenes/markers` and `/images`. Deactivates (removes its
style tag, disconnects its observers) on navigation away. No effect on any
other view.
## Tuning
The primary knobs are declared as constants at the top of the load-queue
section in `mobileWallLayout.js`:
| Constant | Default | Effect |
|---|---:|---|
| `threshold` (IntersectionObserver) | `0.1` | Lower = scroll feels continuous (more clips partially playing); higher = stricter focus on the clip in view. |
| `_MAX_CONCURRENT_LOADS` | `2` | Higher = entire page finishes sooner but each clip loads slower; lower = top clips get more uncontested bandwidth but the tail waits longer. |
| `_LOAD_ADVANCE_MS` | `500` | Short = every video starts fetching sooner (better for degrading reception); long = top clips get more solo time before the pipe re-splits. |
## Retention
`react-photo-gallery@8.0.0` does not virtualize — every photo in the
current page stays in the DOM. Video elements therefore keep their
downloaded bytes for the lifetime of the page, so scrolling back to a
clip you've already buffered resumes instantly even if the network has
since dropped. Retention scope is the current page; page-change remounts
the gallery and resets state.
## Compatibility
Requires `IntersectionObserver`, `MutationObserver`, `WeakMap`, `WeakSet`,
`Element.isConnected`. All supported by mobile Safari 12.1+, Chrome,
Firefox, and Edge.

View File

@@ -1,86 +1,320 @@
/**
* Mobile Layout Fix — Stash UI Plugin
* =====================================
* Forces full-width single-column layout on the Markers and Images (wall mode)
* pages, where react-photo-gallery sets inline position:absolute offsets that
* cause items to overlap or overflow on mobile.
* Mobile Wall Layout — Stash UI Plugin (display name: scrollFeed)
* =================================================================
* Turns the Markers and Images wall into a scrollable mobile feed:
* full-width single-column layout, play-on-visibility, and DOM-ordered
* video loading so the top of the page is watchable fast on mobile data.
*
* Fix: inject a <style> tag with !important rules that override the library's
* inline styles, making the layout rendering-order-independent. A JS-based
* approach (setting el.style directly) loses to library re-renders; CSS wins
* unconditionally.
* Only active on /scenes/markers and /images; tears down on navigate-away.
*
* The style tag is added when entering /images or /scenes/markers and removed
* on navigation away, so it never affects other views.
* Architecture
* ------------
* <style> tag : overrides react-photo-gallery's absolute-
* positioned brick layout with a full-width
* single-column flow on touch devices.
* IntersectionObserver : plays each <video> at 0.1 visibility, pauses
* below. Allows 23 concurrent playbacks, well
* under iOS Safari's ~20-simultaneous-<video>
* ceiling that this plugin exists to stay under.
* Load queue : DOM-ordered, concurrency-capped (2 in flight).
* Cancels React's parallel src fetches and re-
* issues them top-down so the first clip is
* playable before the 20th has even started.
* The queue advances every _LOAD_ADVANCE_MS
* (fallback) or on canplay (wifi-fast path),
* whichever fires first, so the entire list is
* in-flight within a few seconds — moving into
* a bad-reception area won't strand the bottom
* of the page with zero bytes.
* MutationObserver : watches <body> for SPA navigation and newly
* rendered videos (Stash fills incrementally).
*
* Architecture:
* A single MutationObserver watches for DOM changes caused by Stash's SPA
* navigation and re-evaluates which page is active.
* Why the load queue exists
* -------------------------
* Stash renders <video autoPlay src="..."> on every marker card, so
* the browser fires N parallel HTTP fetches on page mount. Over a
* home-NAS → public-internet → cellular-phone path that's N streams
* sharing one slow uplink — the top video is as far from playable as
* the bottom one. By stripping src at registration (video.load()
* aborts the in-flight fetch), pushing to an ordered queue, and
* restoring src top-down, the top clips get the first and largest
* slice of bandwidth.
*
* Retention
* ---------
* react-photo-gallery v8 does NOT virtualize — every photo on the
* current page stays in the DOM. A <video>'s downloaded bytes persist
* for the page's lifetime, so scrolling back after a network drop
* resumes instantly. Stash paginates (min 20/page), so retention
* scope is the current page; page-change resets state by design.
*
* Style
* -----
* This file uses ES5 idiom (var, function declarations) for broad
* compatibility and because the original pre-scrollFeed plugin did.
* The whole file is wrapped in an IIFE to keep its vars and
* functions out of the global scope — Stash loads every enabled
* UI plugin into the same document, so name collisions between
* plugins would otherwise be possible.
*/
// ── Images + Markers pages: CSS injection for mobile full-width layout ────────
// Both /images (wall mode) and /scenes/markers use react-photo-gallery, which
// sets inline position:absolute styles. A <style> tag with !important beats
// inline styles regardless of render timing, avoiding the race condition that
// JS-based inline overrides suffer from.
//
// Device targeting uses `pointer: coarse` (touchscreens) rather than a pixel
// width threshold. A width-only check (e.g. <= 960px) also triggers on narrow
// desktop windows; pointer coarseness correctly identifies touch devices
// regardless of window size, and CSS media queries re-evaluate automatically on
// any relevant change — no JS resize listener needed.
(function () {
'use strict';
var _imagesStyleTag = null;
// ── CSS injection: full-width single-column layout on touch devices ───────
// Stash's wall uses react-photo-gallery, which sets inline position:absolute
// offsets for a multi-column brick layout. On narrow viewports those offsets
// cause items to overlap or overflow. A <style> tag with !important beats
// inline styles regardless of render timing, avoiding the race condition
// that direct JS style manipulation suffers from.
//
// pointer:coarse (touchscreens) is a stricter device test than
// window.innerWidth — a width-only check also triggers on narrow desktop
// windows.
// Uses !important throughout so these rules win over react-photo-gallery's
// inline `style="position:absolute; top:Xpx; left:Ypx"` attributes.
// Wrapped in a pointer:coarse media query so the rules are inert on desktop.
var _IMAGES_CSS = [
'@media (pointer: coarse) {',
' div.react-photo-gallery--gallery {',
' display: block !important;',
' }',
' .wall-item {',
' position: relative !important;', /* pull items back into normal flow */
' width: 100% !important;',
' height: auto !important;',
' top: auto !important;', /* neutralise calculated pixel offsets */
' left: auto !important;',
' display: block !important;',
' margin-bottom: 10px !important;',
' }',
' .wall-item img, .wall-item video {',
' width: 100% !important;',
' height: auto !important;',
' object-fit: contain !important;',
' }',
'}'
].join('\n');
var _styleTag = null;
function updateImagesPageFix() {
var href = window.location.href;
var onTargetPage = href.includes('/images') || href.includes('scenes/markers');
var _CSS = [
'@media (pointer: coarse) {',
' div.react-photo-gallery--gallery {',
' display: block !important;',
' }',
' .wall-item {',
' position: relative !important;', /* pull items back into normal flow */
' width: 100% !important;',
' height: auto !important;',
' top: auto !important;', /* neutralise calculated pixel offsets */
' left: auto !important;',
' display: block !important;',
' margin-bottom: 10px !important;',
' }',
' .wall-item img, .wall-item video {',
' width: 100% !important;',
' height: auto !important;',
' object-fit: contain !important;',
' }',
'}'
].join('\n');
if (onTargetPage && !_imagesStyleTag) {
// Entering images or markers page — inject the fix
_imagesStyleTag = document.createElement('style');
_imagesStyleTag.id = 'mobile-layout-fix-images';
_imagesStyleTag.textContent = _IMAGES_CSS;
document.head.appendChild(_imagesStyleTag);
} else if (!onTargetPage && _imagesStyleTag) {
// Leaving — clean up so other pages are unaffected
_imagesStyleTag.remove();
_imagesStyleTag = null;
// ── Load queue: cancel the parallel-fetch storm, re-issue in DOM order ────
// Concurrency cap. Top _MAX_CONCURRENT_LOADS videos start with the most
// uncontested bandwidth; higher values re-split the pipe sooner.
var _MAX_CONCURRENT_LOADS = 2;
// How long a video holds a concurrency slot before the queue advances past
// it, regardless of canplay. On cellular, canplay can take 35s per video,
// which starves the tail — we want every video to have started fetching
// within a few seconds of page load so degrading reception doesn't strand
// the bottom of the page with zero bytes. 500ms × 10 batches = all 20 in
// flight by ~5s.
var _LOAD_ADVANCE_MS = 500;
var _videoSrcs = null; // WeakMap<HTMLVideoElement, string> — saved src
var _loadQueue = null; // Array<HTMLVideoElement>, head = next to load
var _loading = null; // Set<HTMLVideoElement>, in-flight
var _wantsToPlay = null; // WeakSet<HTMLVideoElement>, currently in view
function clearVideoSrc(video) {
var src = video.getAttribute('src');
if (!src) return;
_videoSrcs.set(video, src);
video.removeAttribute('src');
// load() on a src-less element resets media state AND cancels any
// pending network request the browser had started for the old src.
// Wrapped in try/catch because some browsers throw if load() is
// called during a pending media state transition; swallowing is
// safe because we're explicitly resetting anyway.
try { video.load(); } catch (e) {}
}
}
// ── Shared MutationObserver ───────────────────────────────────────────────────
// Stash is a React SPA; page "navigation" is DOM mutation, not a real load.
// Observing childList + subtree on body catches both navigation and lazy-
// loaded gallery content without needing a polling interval.
function restoreVideoSrc(video) {
if (video.getAttribute('src')) return;
var src = _videoSrcs.get(video);
if (!src) return;
video.setAttribute('preload', 'auto');
video.setAttribute('src', src);
try { video.load(); } catch (e) {}
}
var observer = new MutationObserver(updateImagesPageFix);
observer.observe(document.body, { childList: true, subtree: true });
function beginLoading(video) {
if (_loading.has(video)) return;
_loading.add(video);
restoreVideoSrc(video);
// Run immediately for whichever page is loaded first
updateImagesPageFix();
// Capture the current _loading Set so a stale advance (e.g. if the
// plugin deactivates before the timeout fires) can detect it and
// no-op, instead of mutating a freshly-constructed successor Set.
var loading = _loading;
var advanced = false;
var advance = function () {
if (advanced) return;
advanced = true;
if (loading !== _loading) return; // plugin deactivated / reset
loading.delete(video);
processLoadQueue();
};
// canplay: video is playable. Start it if the user is looking at it,
// and advance the queue early (wifi-fast path).
var onCanPlay = function () {
video.removeEventListener('canplay', onCanPlay);
tryPlay(video);
advance();
};
video.addEventListener('canplay', onCanPlay);
// Fallback for slow networks: advance even if canplay is still many
// seconds away, so the tail of the queue starts fetching in time.
setTimeout(advance, _LOAD_ADVANCE_MS);
}
function processLoadQueue() {
if (!_loading || !_loadQueue) return;
while (_loading.size < _MAX_CONCURRENT_LOADS && _loadQueue.length > 0) {
var video = _loadQueue.shift();
if (!video.isConnected) continue; // React unmounted it
if (video.getAttribute('src')) continue; // already has a src
beginLoading(video);
}
}
// If the user scrolls past the current load window to a video that hasn't
// loaded yet, move it to the head of the queue so the scheduler picks it
// up as soon as a slot frees. Doesn't pre-empt an in-flight load — just
// reorders the waiting list.
function bumpToHead(video) {
if (!_loadQueue) return;
var idx = _loadQueue.indexOf(video);
if (idx <= 0) return;
_loadQueue.splice(idx, 1);
_loadQueue.unshift(video);
processLoadQueue();
}
function tryPlay(video) {
if (!_wantsToPlay || !_wantsToPlay.has(video)) return;
if (video.readyState < 2) return; // not enough data yet
// play() returns a Promise that rejects if interrupted (e.g. paused
// again immediately). Swallow — nothing actionable.
var p = video.play();
if (p && typeof p.catch === 'function') p.catch(function () {});
}
// ── Play-on-visibility ────────────────────────────────────────────────────
var _ioPlay = null;
function onPlayIntersect(entries) {
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
var video = entry.target;
if (entry.isIntersecting) {
_wantsToPlay.add(video);
// User is looking at this clip — prioritize its load.
bumpToHead(video);
// iOS Safari may evict decoders under memory pressure, leaving
// the element with readyState 0. Re-load if so.
if (video.readyState === 0 && video.getAttribute('src')) {
try { video.load(); } catch (e) {}
}
tryPlay(video);
} else {
_wantsToPlay.delete(video);
video.pause();
}
}
}
// Find any <video> in the gallery we haven't observed yet, pause it
// (defuse the autoplay stampede — Stash sets autoPlay on every card, so
// a 20-card page fires 20 simultaneous play() calls before our IO's
// first async callback can land), swap its src into the load queue, and
// observe it. Called on every MutationObserver tick so newly rendered
// videos get picked up as Stash fills the DOM.
var _observedVideos = null; // WeakSet — dedup MO ticks
function registerGalleryVideos() {
if (!_ioPlay || !_observedVideos) return;
var videos = document.querySelectorAll(
'.react-photo-gallery--gallery video'
);
for (var i = 0; i < videos.length; i++) {
var video = videos[i];
if (_observedVideos.has(video)) continue;
_observedVideos.add(video);
video.pause();
_ioPlay.observe(video);
// Only interpose the load queue if the fetch is still abortable
// (readyState 0/1 on mobile data — bytes haven't arrived yet).
// If somehow the video is already playable, don't waste those
// bytes.
if (video.readyState < 2 && video.getAttribute('src')) {
clearVideoSrc(video);
_loadQueue.push(video);
}
}
processLoadQueue();
}
function activateVideoBehavior() {
if (_ioPlay) return;
_videoSrcs = new WeakMap();
_loadQueue = [];
_loading = new Set();
_wantsToPlay = new WeakSet();
_observedVideos = new WeakSet();
_ioPlay = new IntersectionObserver(onPlayIntersect, { threshold: 0.1 });
}
function deactivateVideoBehavior() {
if (!_ioPlay) return;
_ioPlay.disconnect();
_ioPlay = null;
// Nulling _loading here makes any still-pending `advance` closures
// inert: their captured `loading` ref no longer === _loading, so
// they return without mutating the new plugin state.
_videoSrcs = null;
_loadQueue = null;
_loading = null;
_wantsToPlay = null;
_observedVideos = null;
}
// ── Page change + DOM mutation entry point ────────────────────────────────
// Stash is a React SPA; "navigation" is a DOM mutation, not a page load.
// One observer on <body> catches both navigation and incremental gallery
// rendering, so there's no need for a polling interval. The WeakSet dedup
// in registerGalleryVideos keeps the per-tick cost to a single
// querySelectorAll.
function updateForCurrentPage() {
var path = window.location.pathname;
var onTargetPage = path === '/images' || path === '/scenes/markers';
if (onTargetPage) {
if (!_styleTag) {
_styleTag = document.createElement('style');
_styleTag.id = 'mobileWallLayout-style';
_styleTag.textContent = _CSS;
document.head.appendChild(_styleTag);
}
activateVideoBehavior();
registerGalleryVideos();
} else {
if (_styleTag) {
_styleTag.remove();
_styleTag = null;
}
deactivateVideoBehavior();
}
}
var observer = new MutationObserver(updateForCurrentPage);
observer.observe(document.body, { childList: true, subtree: true });
// Run immediately for whichever page loaded first
updateForCurrentPage();
})();

View File

@@ -1,9 +1,9 @@
name: Mobile Wall Layout
name: scrollFeed
description: >
On the Markers and Images pages, forces the wall-mode gallery to render as a
single full-width column on mobile devices (phones in portrait or landscape,
up to ~960px wide). Tablets and desktops are unaffected.
version: 1.0
A scrolling feed of your favorite marker clips, designed for private
one-handed mobile viewing. No scene navigation — just scroll for the next
clip. Pairs with the preview-cap patch (linked) for clip-length playback.
version: 3.0
url: https://discourse.stashapp.cc/t/mobile-wall-layout/6160
ui:
javascript: