mirror of
https://github.com/stashapp/CommunityScripts.git
synced 2026-05-30 20:14:37 -05:00
[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:
@@ -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 2–3 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.
|
||||
|
||||
@@ -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 2–3 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 3–5s 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();
|
||||
})();
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user