diff --git a/plugins/watchlater/watchlater.css b/plugins/watchlater/watchlater.css new file mode 100644 index 00000000..20e367f7 --- /dev/null +++ b/plugins/watchlater/watchlater.css @@ -0,0 +1,213 @@ +.watch-later-overlay { + position: absolute; + bottom: 0.4rem; + left: 0.4rem; + z-index: 5; +} +.watch-later-btn { + background: rgba(0, 0, 0, 0.55) !important; + border: none !important; + border-radius: 4px; + padding: 0.2rem 0.4rem !important; + line-height: 1; + /* the icon is an emoji, not a vector glyph: "color" doesn't tint it, only "filter" does */ + filter: grayscale(1) brightness(1.5); +} +.watch-later-btn:hover { + filter: grayscale(1) brightness(2); +} +.watch-later-btn.watch-later-on { + filter: none; +} +.watch-later-btn.watch-later-on:hover { + filter: brightness(1.2); +} + +.watchlater-nav-link { + color: inherit; + text-decoration: none; +} + + +.watchlater-page { + padding: 1rem 1.5rem; +} + +.watchlater-page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.watchlater-auto-removed-note { + font-size: 0.8rem; + opacity: 0.6; + margin: 0.4rem 0 0; +} + +.watchlater-page-links { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.watchlater-list-selector { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.6rem; + flex-wrap: wrap; +} +.watchlater-list-tab { + background: rgba(255, 255, 255, 0.06); + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.15) !important; + border-radius: 999px; + padding: 0.2rem 0.8rem !important; + font-size: 0.85rem; + opacity: 0.75; +} +.watchlater-list-tab:hover { + opacity: 1; +} +.watchlater-list-tab-active { + background: rgba(255, 255, 255, 0.16) !important; + border-color: rgba(255, 255, 255, 0.4) !important; + opacity: 1; + font-weight: 600; +} +.watchlater-new-list-btn { + font-size: 0.8rem; + opacity: 0.6; +} +.watchlater-new-list-btn:hover { + opacity: 1; +} + +.watchlater-move-select-wrap { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.85rem; + opacity: 0.75; +} +.watchlater-move-select { + flex: 0 0 auto; + max-width: 130px; + background: #fff; + color: #111 !important; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + padding: 0.15rem 0.3rem; + font-size: 0.8rem; +} +.watchlater-move-select option { + color: #111; + background: #fff; +} + +.watchlater-kofi-link { + font-size: 0.85rem; + opacity: 0.8; + text-decoration: none; + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 999px; + padding: 0.25rem 0.75rem; + white-space: nowrap; +} +.watchlater-kofi-link:hover { + opacity: 1; + border-color: rgba(255, 255, 255, 0.35); + color: inherit; +} + +.watchlater-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-top: 1rem; +} + +.watchlater-row { + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.3rem 0.6rem; +} + +.watchlater-row-thumb-link { + position: relative; + display: block; + flex: 0 0 auto; +} + +.watchlater-row-thumb { + width: 140px; + aspect-ratio: 16 / 9; + object-fit: cover; + display: block; + background: #111; + border-radius: 4px; +} + +.watchlater-row-duration { + position: absolute; + bottom: 0.15rem; + right: 0.15rem; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 0.65rem; + padding: 0.05rem 0.3rem; + border-radius: 3px; +} + +.watchlater-row-body { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.watchlater-row-title { + font-weight: 600; + text-decoration: none; + color: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.watchlater-row-studio { + font-size: 0.8rem; + opacity: 0.7; +} + +.watchlater-row-performers { + font-size: 0.8rem; + opacity: 0.85; +} + +.watchlater-row-tags { + font-size: 0.75rem; + opacity: 0.55; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.watchlater-remove-btn { + flex: 0 0 auto; + font-size: 0.9rem; + opacity: 0.6; +} +.watchlater-remove-btn:hover { + opacity: 1; + color: #e05555 !important; +} diff --git a/plugins/watchlater/watchlater.js b/plugins/watchlater/watchlater.js new file mode 100644 index 00000000..00b99d1f --- /dev/null +++ b/plugins/watchlater/watchlater.js @@ -0,0 +1,591 @@ +(function () { + "use strict"; + const api = window.PluginApi; + const { React, patch, libraries, register } = api; + // Use Link (not a plain ): a plain triggers a full page load, which hits the + // server and 404s on /plugin/watchlater (the route only exists client-side, not server-side) - + // real bug found and fixed by testing live in the browser. + const { Link } = libraries.ReactRouterDOM; + + const TAG_NAME = "Watch Later"; + const LIST_PREFIX = "Watch Later: "; + const KOFI_URL = "https://ko-fi.com/greenthumb80"; + const FEEDBACK_URL = "https://github.com/spesometro2026/stash-watchlater-plugin/issues/new"; + let tagId = null; + let tagPromise = null; + // ids of the root tag + every sub-list tag, so we can tell "any Watch Later membership" + // apart from unrelated tags when moving a scene between lists. Populated by ensureLists(). + let familyIds = new Set(); + + async function gql(query, variables) { + const res = await fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ query, variables }), + }); + const out = await res.json(); + if (out.errors) throw new Error(out.errors[0].message); + return out.data; + } + + function ensureTag() { + if (tagPromise) return tagPromise; + tagPromise = (async () => { + const found = await gql( + "query($f: TagFilterType) { findTags(tag_filter: $f) { tags { id name } } }", + { f: { name: { value: TAG_NAME, modifier: "EQUALS" } } } + ); + if (found.findTags.tags.length) { + tagId = found.findTags.tags[0].id; + familyIds.add(tagId); + return tagId; + } + const created = await gql( + "mutation($input: TagCreateInput!) { tagCreate(input: $input) { id } }", + { input: { name: TAG_NAME, description: "Created by the Watch Later plugin" } } + ); + tagId = created.tagCreate.id; + familyIds.add(tagId); + return tagId; + })(); + return tagPromise; + } + + // Lists = the root "Watch Later" tag ("All") plus its child tags ("Watch Later: "). + // Kept as plain Stash tag hierarchy - no state of our own, browsable/filterable outside the + // plugin too, same reasoning as the root tag itself. + async function ensureLists() { + const rootId = await ensureTag(); + const d = await gql( + "query($id: ID!) { findTag(id: $id) { children { id name } } }", + { id: rootId } + ); + const children = (d.findTag && d.findTag.children) || []; + familyIds = new Set([rootId, ...children.map((c) => c.id)]); + return { + rootId, + lists: [ + { id: rootId, name: "All" }, + ...children.map((c) => ({ + id: c.id, + name: c.name.startsWith(LIST_PREFIX) ? c.name.slice(LIST_PREFIX.length) : c.name, + })), + ], + }; + } + + async function createList(name) { + const rootId = await ensureTag(); + const created = await gql( + "mutation($input: TagCreateInput!) { tagCreate(input: $input) { id } }", + { + input: { + name: `${LIST_PREFIX}${name}`, + parent_ids: [rootId], + description: "Watch Later list, created by the Watch Later plugin", + }, + } + ); + familyIds.add(created.tagCreate.id); + return created.tagCreate.id; + } + + async function removeFromWatchLater(scene, listTagId) { + const tid = listTagId || (await ensureTag()); + const currentIds = (scene.tags || []).map((t) => t.id); + await gql( + "mutation($input: SceneUpdateInput!) { sceneUpdate(input: $input) { id } }", + { input: { id: scene.id, tag_ids: currentIds.filter((x) => x !== tid) } } + ); + } + + // Moves a scene to exactly one list: drops any other Watch Later family tag it might carry + // (root or another sub-list) and applies the target one, leaving unrelated tags untouched. + async function moveSceneToList(scene, targetListId) { + const currentIds = (scene.tags || []).map((t) => t.id); + const withoutFamily = currentIds.filter((x) => !familyIds.has(x)); + await gql( + "mutation($input: SceneUpdateInput!) { sceneUpdate(input: $input) { id } }", + { input: { id: scene.id, tag_ids: [...withoutFamily, targetListId] } } + ); + } + + // ------------------------------------------------------------------ card button (grid) + function WatchLaterButton({ scene }) { + // "on" is real React state (not re-derived from scene.tags on every render): mutating + // scene.tags in place, like the previous version did, does NOT trigger a re-render on its + // own - that's the root cause of the old "needs a reload to see the change" bug. Now it's + // flipped explicitly with setOn once the mutation succeeds. + const [on, setOn] = React.useState( + !!(tagId && (scene.tags || []).some((t) => t.id === tagId)) + ); + const [busy, setBusy] = React.useState(false); + + React.useEffect(() => { + let alive = true; + ensureTag().then((tid) => { + if (alive) setOn((scene.tags || []).some((t) => t.id === tid)); + }); + return () => { + alive = false; + }; + }, []); + + async function toggle(e) { + e.preventDefault(); + e.stopPropagation(); + if (busy) return; + const prevOn = on; + const nextOn = !on; + // Optimistic: flip the color immediately, don't make the click feel dead while the + // GraphQL round-trip is in flight. Roll back on failure. + setOn(nextOn); + setBusy(true); + try { + const tid = await ensureTag(); + const currentIds = (scene.tags || []).map((t) => t.id); + const newIds = nextOn + ? [...currentIds, tid] + : currentIds.filter((x) => x !== tid); + await gql( + "mutation($input: SceneUpdateInput!) { sceneUpdate(input: $input) { id tags { id } } }", + { input: { id: scene.id, tag_ids: newIds } } + ); + } catch (err) { + console.error("[Watch Later] toggle error:", err); + setOn(prevOn); + } finally { + setBusy(false); + } + } + + return React.createElement( + "div", + { className: "watch-later-overlay" }, + React.createElement( + "button", + { + className: + "watch-later-btn minimal" + (on ? " watch-later-on" : ""), + title: on ? "Remove from Watch Later" : "Add to Watch Later", + onClick: toggle, + disabled: busy, + }, + "πŸ”–" + ) + ); + } + + patch.instead("SceneCard.Overlays", function (props, _ctx, orig) { + return React.createElement( + React.Fragment, + null, + orig(props, _ctx), + React.createElement(WatchLaterButton, { scene: props.scene }) + ); + }); + + // ------------------------------------------------------------------ dedicated page + function fmtDuration(sec) { + if (!sec) return ""; + sec = Math.round(sec); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const pad = (n) => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; + } + + function WatchLaterCard({ scene, onRemoved, lists, activeListId, onMoved }) { + const [removing, setRemoving] = React.useState(false); + const [moving, setMoving] = React.useState(false); + const file = scene.files && scene.files[0]; + + async function remove(e) { + e.preventDefault(); + e.stopPropagation(); + if (removing) return; + setRemoving(true); + try { + await removeFromWatchLater(scene, activeListId); + onRemoved(scene.id); + } catch (err) { + console.error("[Watch Later] remove error:", err); + setRemoving(false); + } + } + + async function moveTo(e) { + const targetId = e.target.value; + if (!targetId || targetId === activeListId || moving) return; + setMoving(true); + try { + await moveSceneToList(scene, targetId); + onMoved(scene.id); + } catch (err) { + console.error("[Watch Later] move error:", err); + setMoving(false); + } + } + + return React.createElement( + "div", + { className: "watchlater-row" }, + React.createElement( + Link, + { to: `/scenes/${scene.id}`, className: "watchlater-row-thumb-link" }, + React.createElement("img", { + className: "watchlater-row-thumb", + loading: "lazy", + src: scene.paths && scene.paths.screenshot, + alt: "", + }), + file && file.duration + ? React.createElement( + "span", + { className: "watchlater-row-duration" }, + fmtDuration(file.duration) + ) + : null + ), + React.createElement( + "div", + { className: "watchlater-row-body" }, + React.createElement( + Link, + { to: `/scenes/${scene.id}`, className: "watchlater-row-title" }, + scene.title || (file ? file.basename : "") || `Scene #${scene.id}` + ), + scene.studio + ? React.createElement( + "div", + { className: "watchlater-row-studio" }, + scene.studio.name + ) + : null, + scene.performers && scene.performers.length + ? React.createElement( + "div", + { className: "watchlater-row-performers" }, + scene.performers.map((p) => p.name).join(", ") + ) + : null, + // Watch Later family tags (root + every list) aren't informative here, exclude them. + scene.tags && scene.tags.some((t) => !familyIds.has(t.id)) + ? React.createElement( + "div", + { className: "watchlater-row-tags" }, + scene.tags + .filter((t) => !familyIds.has(t.id)) + .map((t) => t.name) + .join(" Β· ") + ) + : null + ), + lists && lists.length > 1 + ? React.createElement( + "div", + { className: "watchlater-move-select-wrap", title: "Move to another list" }, + "➜", + React.createElement( + "select", + { + className: "watchlater-move-select", + value: activeListId, + onChange: moveTo, + disabled: moving, + }, + lists.map((l) => + React.createElement("option", { key: l.id, value: l.id }, l.name) + ) + ) + ) + : null, + React.createElement( + "button", + { + className: "watchlater-remove-btn minimal", + title: "Remove from this list", + onClick: remove, + disabled: removing, + }, + "πŸ”–" + ) + ); + } + + function WatchLaterPage() { + const [scenes, setScenes] = React.useState(null); + const [error, setError] = React.useState(null); + const [autoRemoved, setAutoRemoved] = React.useState(0); + const [lists, setLists] = React.useState(null); + const [activeListId, setActiveListId] = React.useState(null); + + React.useEffect(() => { + let alive = true; + ensureLists().then(({ rootId, lists: ls }) => { + if (!alive) return; + setLists(ls); + setActiveListId((prev) => prev || rootId); + }); + return () => { + alive = false; + }; + }, []); + + // "updated_at" is used as a stand-in for "date added to a list": Stash doesn't keep a + // per-tag timestamp, but tagging a scene bumps its updated_at - so the most recently added + // scene ends up on top, as requested ("sort by when it was added, not by name"). + const load = React.useCallback(async () => { + if (!activeListId) return; + try { + const d = await gql( + `query($id: ID!) { + findScenes( + scene_filter: { tags: { modifier: INCLUDES, value: [$id] } } + filter: { per_page: -1, sort: "updated_at", direction: DESC } + ) { + count + scenes { + id title date updated_at play_history + studio { name } + files { basename duration } + paths { screenshot } + tags { id name } + performers { id name } + } + } + }`, + { id: activeListId } + ); + const fetched = d.findScenes.scenes; + + // Auto-remove scenes watched since they were added to this list: "updated_at" is our + // only proxy for "when added" (see the comment above), so a scene counts as + // watched-since-add if any play_history entry is newer than it. Fires the removal in + // the background; the UI already reflects the filtered list. + const stillPending = []; + const toAutoRemove = []; + for (const s of fetched) { + const watchedSinceAdd = (s.play_history || []).some( + (t) => new Date(t) > new Date(s.updated_at) + ); + (watchedSinceAdd ? toAutoRemove : stillPending).push(s); + } + setScenes(stillPending); + setAutoRemoved(toAutoRemove.length); + for (const s of toAutoRemove) { + removeFromWatchLater(s, activeListId).catch((err) => + console.error("[Watch Later] auto-remove error:", err) + ); + } + } catch (err) { + console.error("[Watch Later] load error:", err); + setError(String(err)); + } + }, [activeListId]); + + React.useEffect(() => { + load(); + }, [load]); + + async function newList() { + const name = window.prompt("New list name:"); + if (!name || !name.trim()) return; + try { + const id = await createList(name.trim()); + setLists((prev) => [...(prev || []), { id, name: name.trim() }]); + setActiveListId(id); + } catch (err) { + console.error("[Watch Later] create list error:", err); + } + } + + function onMoved(id) { + setScenes((prev) => (prev || []).filter((s) => s.id !== id)); + } + + function onRemoved(id) { + setScenes((prev) => (prev || []).filter((s) => s.id !== id)); + } + + function exportList() { + const rows = (scenes || []).map((s) => ({ + title: s.title || (s.files && s.files[0] && s.files[0].basename) || "", + studio: (s.studio && s.studio.name) || "", + performers: (s.performers || []).map((p) => p.name).join(", "), + date: s.date || "", + duration_seconds: (s.files && s.files[0] && s.files[0].duration) || null, + url: `${location.origin}/scenes/${s.id}`, + })); + const blob = new Blob([JSON.stringify(rows, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "watch-later.json"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + const exportLink = React.createElement( + "a", + { + className: "watchlater-kofi-link", + href: "#", + title: "Export this list as JSON", + onClick: (e) => { + e.preventDefault(); + exportList(); + }, + }, + "⬇ Export" + ); + + const kofiLink = React.createElement( + "a", + { + className: "watchlater-kofi-link", + href: KOFI_URL, + target: "_blank", + rel: "noopener noreferrer", + title: "Support this plugin", + }, + "β˜• Buy me a coffee" + ); + + const feedbackLink = React.createElement( + "a", + { + className: "watchlater-kofi-link", + href: FEEDBACK_URL, + target: "_blank", + rel: "noopener noreferrer", + title: "Suggest a feature or report a bug", + }, + "πŸ’‘ Suggest a feature" + ); + + const pageLinks = React.createElement( + "div", + { className: "watchlater-page-links" }, + exportLink, + feedbackLink, + kofiLink + ); + + const listSelector = + lists && lists.length + ? React.createElement( + "div", + { className: "watchlater-list-selector" }, + lists.map((l) => + React.createElement( + "button", + { + key: l.id, + className: + "minimal watchlater-list-tab" + + (l.id === activeListId ? " watchlater-list-tab-active" : ""), + onClick: () => setActiveListId(l.id), + }, + l.name + ) + ), + React.createElement( + "button", + { className: "minimal watchlater-new-list-btn", onClick: newList }, + "+ New list" + ) + ) + : null; + + if (error) { + return React.createElement( + "div", + { className: "watchlater-page" }, + React.createElement("h3", null, "Watch Later"), + React.createElement("p", { className: "text-danger" }, "Error: " + error) + ); + } + + if (scenes === null) { + return React.createElement( + "div", + { className: "watchlater-page" }, + React.createElement("h3", null, "Watch Later"), + React.createElement("p", null, "Loading…") + ); + } + + return React.createElement( + "div", + { className: "watchlater-page" }, + React.createElement( + "div", + { className: "watchlater-page-header" }, + React.createElement("h3", null, `Watch Later (${scenes.length})`), + pageLinks + ), + listSelector, + autoRemoved > 0 + ? React.createElement( + "p", + { className: "watchlater-auto-removed-note" }, + `${autoRemoved} scene${autoRemoved === 1 ? "" : "s"} auto-removed: already watched since being added.` + ) + : null, + scenes.length === 0 + ? React.createElement( + "p", + null, + "Empty for now. Click the bookmark icon on a scene to add it here." + ) + : React.createElement( + "div", + { className: "watchlater-list" }, + scenes.map((s) => + React.createElement(WatchLaterCard, { + key: s.id, + scene: s, + onRemoved, + onMoved, + lists, + activeListId, + }) + ) + ) + ); + } + + register.route("/plugin/watchlater", WatchLaterPage); + + patch.before("MainNavBar.UtilityItems", function (props) { + return [ + { + children: React.createElement( + React.Fragment, + null, + props.children, + React.createElement( + Link, + { + className: "nav-utility watchlater-nav-link", + to: "/plugin/watchlater", + title: "Watch Later", + }, + React.createElement( + "button", + { className: "minimal d-flex align-items-center h-100" }, + "πŸ”–" + ) + ) + ), + }, + ]; + }); +})(); diff --git a/plugins/watchlater/watchlater.yml b/plugins/watchlater/watchlater.yml new file mode 100644 index 00000000..b1df2e72 --- /dev/null +++ b/plugins/watchlater/watchlater.yml @@ -0,0 +1,11 @@ +name: Watch Later +description: Adds a quick bookmark button on scene cards to add/remove a "Watch Later" tag (auto-created on first use), plus a dedicated page to browse your queue. +version: 1.0.0 +url: https://github.com/spesometro2026/stash-watchlater-plugin +ui: + javascript: + - watchlater.js + css: + - watchlater.css + assets: + /: .