Discover v2: per-category pagination, personalization, hide-owned + perf
Performance: - Batched ownership: new db.library_ids_for_tmdb() resolves a whole rail in one query per kind. _stamp_owned (now also used by search + trending) groups by kind, so a full Discover page drops from ~500 connections to a couple per rail. Function/data: - 'See all' on every rail opens it as a paged grid (Load more); the filter bar's Browse routes through the same generic category grid with a back button + title. - Personalized 'Because you like <Genre>' rails seeded from your most-owned genres (new db.top_owned_genres + /discover/taste endpoint). - 'Hide owned' toggle drops in-library titles from every rail/grid (CSS class, instant). Visual vibes: - Ambient page-top color bleed that follows the current hero slide's hue. - Rail edge-fade mask, gentle fade-in on load, per-title hue glow on card hover. Tests: +4 (batched id map, server scoping, one-query-per-kind stamp, top genres). Full video enrichment + database suites: 145 passed.
This commit is contained in:
parent
97a2023139
commit
5c9b43a175
7 changed files with 312 additions and 147 deletions
|
|
@ -34,6 +34,23 @@ def register_routes(bp):
|
|||
items = []
|
||||
return jsonify({"items": items})
|
||||
|
||||
@bp.route("/discover/taste", methods=["GET"])
|
||||
def video_discover_taste():
|
||||
"""The user's most-owned genres (movies + shows) → personalized rails."""
|
||||
from . import get_video_db
|
||||
try:
|
||||
from core.video.sources import resolve_video_server
|
||||
srv = resolve_video_server()
|
||||
except Exception:
|
||||
srv = None
|
||||
db = get_video_db()
|
||||
try:
|
||||
return jsonify({"movie": db.top_owned_genres("movie", srv, 6),
|
||||
"show": db.top_owned_genres("show", srv, 6)})
|
||||
except Exception:
|
||||
logger.exception("discover taste failed")
|
||||
return jsonify({"movie": [], "show": []})
|
||||
|
||||
@bp.route("/discover/genres", methods=["GET"])
|
||||
def video_discover_genres():
|
||||
"""Genre id→name maps for both kinds (powers the genre rails + filter)."""
|
||||
|
|
|
|||
|
|
@ -279,11 +279,7 @@ class VideoEnrichmentEngine:
|
|||
except Exception:
|
||||
logger.exception("video search failed for %r", query)
|
||||
return []
|
||||
srv = self._server()
|
||||
for r in results:
|
||||
if r.get("kind") in ("movie", "show") and r.get("tmdb_id"):
|
||||
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv)
|
||||
return results
|
||||
return self._stamp_owned(results)
|
||||
|
||||
def trending(self) -> list:
|
||||
"""Trending titles for the idle search page, annotated owned/not."""
|
||||
|
|
@ -298,21 +294,26 @@ class VideoEnrichmentEngine:
|
|||
except Exception:
|
||||
logger.exception("video trending failed")
|
||||
return []
|
||||
# Re-annotate ownership fresh each call (cheap) so it tracks the library.
|
||||
srv = self._server()
|
||||
for r in cached:
|
||||
if r.get("tmdb_id"):
|
||||
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv)
|
||||
return cached
|
||||
# Re-annotate ownership fresh each call (batched) so it tracks the library.
|
||||
return self._stamp_owned(cached)
|
||||
|
||||
# ── discover (browse TMDB lists; owned titles annotated) ──────────────────
|
||||
def _stamp_owned(self, items):
|
||||
"""Annotate each movie/show with its library_id for the active server —
|
||||
stamped fresh (not cached with ownership) so 'In Library' tracks scans."""
|
||||
stamped fresh (not cached with ownership) so 'In Library' tracks scans.
|
||||
Batched: one query per kind for the whole list, not one per item."""
|
||||
srv = self._server()
|
||||
by_kind: dict = {}
|
||||
for r in items or []:
|
||||
if r.get("kind") in ("movie", "show") and r.get("tmdb_id"):
|
||||
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv)
|
||||
by_kind.setdefault(r["kind"], []).append(r["tmdb_id"])
|
||||
maps = {k: self.db.library_ids_for_tmdb(k, ids, srv) for k, ids in by_kind.items()}
|
||||
for r in items or []:
|
||||
if r.get("kind") in ("movie", "show") and r.get("tmdb_id"):
|
||||
try:
|
||||
r["library_id"] = maps.get(r["kind"], {}).get(int(r["tmdb_id"]))
|
||||
except (TypeError, ValueError):
|
||||
r["library_id"] = None
|
||||
return items
|
||||
|
||||
def discover_curated(self, key, page=1) -> list:
|
||||
|
|
|
|||
|
|
@ -365,6 +365,66 @@ class VideoDatabase:
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
def library_ids_for_tmdb(self, kind: str, tmdb_ids, server_source=None) -> dict:
|
||||
"""{tmdb_id: library_row_id} for the owned subset of ``tmdb_ids`` on the
|
||||
active server. Batched (chunked IN) so a whole Discover rail costs one
|
||||
query per kind instead of one connection+query per item."""
|
||||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||||
out: dict = {}
|
||||
if not table:
|
||||
return out
|
||||
ids = []
|
||||
for x in (tmdb_ids or []):
|
||||
try:
|
||||
ids.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not ids:
|
||||
return out
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
for i in range(0, len(ids), 400): # stay under SQLite's variable cap
|
||||
chunk = ids[i:i + 400]
|
||||
ph = ",".join("?" * len(chunk))
|
||||
sql = f"SELECT id, tmdb_id FROM {table} WHERE tmdb_id IN ({ph})"
|
||||
args = list(chunk)
|
||||
if server_source:
|
||||
sql += " AND server_source=?"
|
||||
args.append(server_source)
|
||||
for row in conn.execute(sql, args):
|
||||
out.setdefault(row["tmdb_id"], row["id"]) # first match wins
|
||||
return out
|
||||
except sqlite3.Error:
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def top_owned_genres(self, kind: str, server_source=None, limit: int = 6) -> list:
|
||||
"""The user's most-owned genre names for movies/shows, busiest first —
|
||||
drives Discover's personalized 'Because you like …' rails."""
|
||||
if kind == "movie":
|
||||
link, owner, tbl, owned = "movie_genres", "movie_id", "movies", "t.has_file=1"
|
||||
elif kind == "show":
|
||||
link, owner, tbl, owned = "show_genres", "show_id", "shows", "1=1" # any library show counts
|
||||
else:
|
||||
return []
|
||||
sql = (f"SELECT g.name AS name, COUNT(*) AS c FROM {link} lt "
|
||||
f"JOIN genres g ON g.id = lt.genre_id "
|
||||
f"JOIN {tbl} t ON t.id = lt.{owner} WHERE {owned}")
|
||||
args: list = []
|
||||
if server_source:
|
||||
sql += " AND t.server_source=?"
|
||||
args.append(server_source)
|
||||
sql += " GROUP BY g.name ORDER BY c DESC, g.name LIMIT ?"
|
||||
args.append(int(limit))
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
return [r["name"] for r in conn.execute(sql, args)]
|
||||
except sqlite3.Error:
|
||||
return []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None:
|
||||
"""Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced.
|
||||
Ratings are dynamic, so these overwrite (unlike gap-only metadata)."""
|
||||
|
|
|
|||
|
|
@ -1116,3 +1116,49 @@ def test_engine_discover_swallows_client_errors(db):
|
|||
assert eng.discover_curated("popular_movies") == []
|
||||
assert eng.discover_filter("movie", genre=1) == []
|
||||
assert eng.genre_list("movie") == []
|
||||
|
||||
|
||||
def test_library_ids_for_tmdb_batched(db):
|
||||
m1 = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 10})
|
||||
m2 = db.upsert_movie("plex", {"server_id": "m2", "title": "B", "tmdb_id": 20})
|
||||
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 30, "seasons": []})
|
||||
assert db.library_ids_for_tmdb("movie", [10, 20, 99]) == {10: m1, 20: m2} # 99 not owned
|
||||
assert db.library_ids_for_tmdb("show", [30, 31]) == {30: sid}
|
||||
assert db.library_ids_for_tmdb("movie", []) == {}
|
||||
assert db.library_ids_for_tmdb("bogus", [10]) == {}
|
||||
|
||||
|
||||
def test_library_ids_for_tmdb_scopes_by_server(db):
|
||||
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 10})
|
||||
db.upsert_movie("jellyfin", {"server_id": "j1", "title": "A", "tmdb_id": 11})
|
||||
got = db.library_ids_for_tmdb("movie", [10, 11], server_source="plex")
|
||||
assert 10 in got and 11 not in got # the other server's copy doesn't count
|
||||
|
||||
|
||||
def test_stamp_owned_is_one_query_per_kind(db, monkeypatch):
|
||||
# The whole rail must cost one ownership query per kind, not one per item.
|
||||
db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
|
||||
calls = {"n": 0}
|
||||
real = db.library_ids_for_tmdb
|
||||
def counted(kind, ids, server_source=None):
|
||||
calls["n"] += 1
|
||||
return real(kind, ids, server_source)
|
||||
monkeypatch.setattr(db, "library_ids_for_tmdb", counted)
|
||||
eng = VideoEnrichmentEngine(db, {})
|
||||
items = [{"kind": "movie", "tmdb_id": i, "title": "T%d" % i} for i in range(1, 21)]
|
||||
items.append({"kind": "show", "tmdb_id": 99, "title": "Show"})
|
||||
eng._stamp_owned(items)
|
||||
assert calls["n"] == 2 # one movie query + one show query
|
||||
assert items[0]["library_id"] is not None and items[1]["library_id"] is None
|
||||
|
||||
|
||||
def test_top_owned_genres_orders_by_count(db):
|
||||
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 1,
|
||||
"genres": ["Action", "Drama"], "file": {"relative_path": "a.mkv"}})
|
||||
db.upsert_movie("plex", {"server_id": "m2", "title": "B", "tmdb_id": 2,
|
||||
"genres": ["Action"], "file": {"relative_path": "b.mkv"}})
|
||||
db.upsert_movie("plex", {"server_id": "m3", "title": "C", "tmdb_id": 3,
|
||||
"genres": ["Comedy"]}) # no file → not owned → excluded
|
||||
g = db.top_owned_genres("movie", server_source="plex", limit=5)
|
||||
assert g[0] == "Action" # owned twice → first
|
||||
assert "Drama" in g and "Comedy" not in g # Comedy movie isn't owned
|
||||
|
|
|
|||
|
|
@ -868,7 +868,8 @@
|
|||
a filter bar that flips into a paged grid. Built by
|
||||
video/video-discover.js, styled by .vdsc-* in video-side.css. -->
|
||||
<section class="video-subpage" data-video-subpage="video-discover" hidden>
|
||||
<div class="vdsc-page">
|
||||
<div class="vdsc-page" data-vdsc-page>
|
||||
<div class="vdsc-amb" data-vdsc-amb aria-hidden="true"></div>
|
||||
<div class="vdsc-hero hidden" data-vdsc-hero></div>
|
||||
<div class="vdsc-filterbar">
|
||||
<span class="vdsc-filter-label">Browse</span>
|
||||
|
|
@ -894,10 +895,17 @@
|
|||
<option value="vote_count.desc">Most voted</option>
|
||||
</select>
|
||||
<button class="vdsc-btn" type="button" data-vdsc-apply>Browse</button>
|
||||
<button class="vdsc-btn vdsc-btn--ghost" type="button" data-vdsc-clear>Clear</button>
|
||||
<label class="vdsc-toggle">
|
||||
<input type="checkbox" data-vdsc-hideowned>
|
||||
<span>Hide owned</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="vdsc-shelves" data-vdsc-shelves></div>
|
||||
<div class="vdsc-grid-wrap hidden" data-vdsc-grid-wrap>
|
||||
<div class="vdsc-grid-head">
|
||||
<button class="vdsc-btn vdsc-btn--ghost" type="button" data-vdsc-clear>← Discover</button>
|
||||
<h2 class="vdsc-grid-title" data-vdsc-grid-title></h2>
|
||||
</div>
|
||||
<div class="vsr-loading hidden" data-vdsc-grid-loading>
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Loading…</div>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
* SoulSync — Video Discover page (isolated, in-app).
|
||||
*
|
||||
* A browse-everything page for TMDB titles you don't own yet: a cross-fading
|
||||
* trending hero, then a deep stack of Netflix-style rails (curated lists, every
|
||||
* genre, a few decades), each lazy-loaded on scroll. A filter bar (kind / genre /
|
||||
* year / sort) flips the whole page into a paged grid. Cards reuse the search
|
||||
* card look + owned/preview ribbon and open the detail page via the shared
|
||||
* soulsync:video-open-detail event. Self-contained IIFE, no globals.
|
||||
* trending hero, personalized "because you like…" rails, then a deep stack of
|
||||
* Netflix-style genre/decade/curated rails — each lazy-loaded on scroll. Every
|
||||
* rail has a "See all" that opens it as a paged grid (Load more). A filter bar
|
||||
* (kind / genre / decade / sort) opens an arbitrary grid; a "Hide owned" toggle
|
||||
* drops titles already in your library. Cards reuse the search card + owned
|
||||
* ribbon and open detail via the shared soulsync:video-open-detail event.
|
||||
* Self-contained IIFE, no globals.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
|
@ -16,10 +18,10 @@
|
|||
|
||||
var state = {
|
||||
loaded: false, wired: false, mode: 'shelves',
|
||||
genres: { movie: [], show: [] },
|
||||
io: null, // IntersectionObserver for lazy rails
|
||||
genres: { movie: [], show: [] }, taste: { movie: [], show: [] },
|
||||
io: null,
|
||||
hero: { items: [], idx: 0, timer: null },
|
||||
filter: { kind: 'movie', genre: '', decade: '', year: '', sort: 'popularity.desc', page: 1, busy: false },
|
||||
cat: { title: '', q: '', page: 1, paginates: true, busy: false },
|
||||
};
|
||||
|
||||
function $(s, r) { return (r || document).querySelector(s); }
|
||||
|
|
@ -29,8 +31,9 @@
|
|||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function hueOf(s) { var h = 0, t = String(s || ''); for (var i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0; return h % 360; }
|
||||
function idMap(list) { var m = {}; (list || []).forEach(function (g) { m[(g.name || '').toLowerCase()] = g.id; }); return m; }
|
||||
|
||||
// ── the rail stack (curated + genre + decade) ─────────────────────────────
|
||||
// ── the rail stack (personalized + curated + genre + decade) ──────────────
|
||||
var CURATED = [
|
||||
{ title: 'Trending This Week', q: 'key=trending' },
|
||||
{ title: 'Popular Movies', q: 'key=popular_movies' },
|
||||
|
|
@ -41,7 +44,6 @@
|
|||
{ title: 'Top Rated Movies', q: 'key=top_movies' },
|
||||
{ title: 'Top Rated Shows', q: 'key=top_shows' },
|
||||
];
|
||||
// Movie genres to surface as their own rails (resolved to ids once loaded).
|
||||
var GENRE_RAILS = ['Action', 'Adventure', 'Comedy', 'Drama', 'Science Fiction',
|
||||
'Thriller', 'Horror', 'Animation', 'Fantasy', 'Romance', 'Documentary', 'Crime'];
|
||||
var DECADE_RAILS = [
|
||||
|
|
@ -52,14 +54,24 @@
|
|||
];
|
||||
|
||||
function buildShelfList() {
|
||||
var shelves = CURATED.slice();
|
||||
var gmap = {};
|
||||
state.genres.movie.forEach(function (g) { gmap[(g.name || '').toLowerCase()] = g.id; });
|
||||
GENRE_RAILS.forEach(function (name) {
|
||||
var id = gmap[name.toLowerCase()];
|
||||
if (id != null) shelves.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' });
|
||||
var out = [], used = {};
|
||||
var gm = idMap(state.genres.movie), gs = idMap(state.genres.show);
|
||||
// personalized first — seeded from what you actually own
|
||||
(state.taste.movie || []).slice(0, 3).forEach(function (name) {
|
||||
var id = gm[name.toLowerCase()];
|
||||
if (id != null) { out.push({ title: 'Because you like ' + name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' }); used['m:' + name.toLowerCase()] = 1; }
|
||||
});
|
||||
return shelves.concat(DECADE_RAILS);
|
||||
(state.taste.show || []).slice(0, 2).forEach(function (name) {
|
||||
var id = gs[name.toLowerCase()];
|
||||
if (id != null) { out.push({ title: 'More ' + name + ' shows', q: 'kind=show&genre=' + id + '&sort=popularity.desc' }); }
|
||||
});
|
||||
out = out.concat(CURATED);
|
||||
GENRE_RAILS.forEach(function (name) {
|
||||
var id = gm[name.toLowerCase()];
|
||||
if (id != null && !used['m:' + name.toLowerCase()])
|
||||
out.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' });
|
||||
});
|
||||
return out.concat(DECADE_RAILS);
|
||||
}
|
||||
|
||||
// ── card (mirrors the search title card: owned ribbon + get button) ───────
|
||||
|
|
@ -81,53 +93,42 @@
|
|||
var sub = [it.year, it.kind === 'movie' ? 'Movie' : 'TV'].filter(Boolean).join(' · ');
|
||||
var cb = window.VideoGet ? VideoGet.cardButton({ kind: it.kind, tmdbId: it.tmdb_id,
|
||||
libraryId: it.library_id, title: it.title, poster: it.poster, status: it.status, source: source }) : '';
|
||||
return '<a class="vsr-card" href="' + href + '" ' +
|
||||
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' + cb +
|
||||
return '<a class="vsr-card' + (owned ? ' vsr-card--owned' : '') + '" href="' + href + '" ' +
|
||||
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id +
|
||||
'" style="--vgm-h:' + hueOf(it.title) + '">' + cb +
|
||||
'<div class="vsr-poster">' + img + ribbon + rating +
|
||||
'<span class="vsr-peek" aria-hidden="true">i</span></div>' +
|
||||
'<div class="vsr-info"><span class="vsr-name" title="' + esc(it.title) + '">' + esc(it.title) +
|
||||
'</span><span class="vsr-sub">' + esc(sub) + '</span></div></a>';
|
||||
}
|
||||
|
||||
function hydrateGet(root) {
|
||||
// shows already on the watchlist paint their eye as "watched"
|
||||
if (window.VideoWatchlist) VideoWatchlist.hydrate(root);
|
||||
}
|
||||
function hydrateGet(root) { if (window.VideoWatchlist) VideoWatchlist.hydrate(root); }
|
||||
|
||||
// ── hero slideshow ────────────────────────────────────────────────────────
|
||||
function loadHero() {
|
||||
fetch('/api/video/discover/hero', { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
state.hero.items = (d && d.items) || [];
|
||||
renderHero();
|
||||
})
|
||||
.then(function (d) { state.hero.items = (d && d.items) || []; renderHero(); })
|
||||
.catch(function () { /* hero is optional chrome */ });
|
||||
}
|
||||
|
||||
function renderHero() {
|
||||
var host = $('[data-vdsc-hero]'); if (!host) return;
|
||||
var items = state.hero.items;
|
||||
if (!items.length) { host.classList.add('hidden'); return; }
|
||||
host.classList.remove('hidden');
|
||||
var slides = items.map(function (it, i) {
|
||||
return '<div class="vdsc-slide' + (i === 0 ? ' vdsc-slide--on' : '') + '" data-vdsc-slide="' + i + '" ' +
|
||||
'style="--vgm-h:' + hueOf(it.title) + ';' + (it.backdrop ? "background-image:url('" + esc(it.backdrop) + "')" : '') + '">' +
|
||||
'<div class="vdsc-slide-scrim"></div></div>';
|
||||
}).join('');
|
||||
var dots = items.map(function (it, i) {
|
||||
return '<button class="vdsc-dot' + (i === 0 ? ' vdsc-dot--on' : '') + '" type="button" ' +
|
||||
'data-vdsc-go="' + i + '" aria-label="Slide ' + (i + 1) + '"></button>';
|
||||
}).join('');
|
||||
host.innerHTML =
|
||||
'<div class="vdsc-slides" data-vdsc-slides>' + slides + '</div>' +
|
||||
'<div class="vdsc-slides">' + items.map(function (it, i) {
|
||||
return '<div class="vdsc-slide' + (i === 0 ? ' vdsc-slide--on' : '') + '" data-vdsc-slide="' + i + '" ' +
|
||||
'style="--vgm-h:' + hueOf(it.title) + ';' + (it.backdrop ? "background-image:url('" + esc(it.backdrop) + "')" : '') + '">' +
|
||||
'<div class="vdsc-slide-scrim"></div></div>';
|
||||
}).join('') + '</div>' +
|
||||
'<div class="vdsc-hero-body" data-vdsc-hero-body></div>' +
|
||||
'<div class="vdsc-dots">' + dots + '</div>';
|
||||
'<div class="vdsc-dots">' + items.map(function (it, i) {
|
||||
return '<button class="vdsc-dot' + (i === 0 ? ' vdsc-dot--on' : '') + '" type="button" data-vdsc-go="' + i + '" aria-label="Slide ' + (i + 1) + '"></button>';
|
||||
}).join('') + '</div>';
|
||||
state.hero.idx = 0;
|
||||
paintHeroBody();
|
||||
startHeroTimer();
|
||||
}
|
||||
|
||||
function paintHeroBody() {
|
||||
var body = $('[data-vdsc-hero-body]'); if (!body) return;
|
||||
var it = state.hero.items[state.hero.idx]; if (!it) return;
|
||||
|
|
@ -137,27 +138,26 @@
|
|||
var pills = [it.kind === 'movie' ? 'Movie' : 'TV', it.year,
|
||||
it.rating ? '★ ' + (Math.round(it.rating * 10) / 10) : null,
|
||||
owned ? 'In Library' : null].filter(Boolean);
|
||||
body.style.setProperty('--vgm-h', hueOf(it.title));
|
||||
var hue = hueOf(it.title);
|
||||
body.style.setProperty('--vgm-h', hue);
|
||||
var pageEl = $('[data-vdsc-page]'); if (pageEl) pageEl.style.setProperty('--vdsc-amb', hue); // ambient bleed
|
||||
body.innerHTML =
|
||||
'<div class="vdsc-hero-eyebrow">' + (owned ? 'In your library' : 'Trending now') + '</div>' +
|
||||
'<h2 class="vdsc-hero-title">' + esc(it.title) + '</h2>' +
|
||||
'<div class="vdsc-hero-pills">' + pills.map(function (p) {
|
||||
return '<span class="vdsc-hero-pill">' + esc(p) + '</span>'; }).join('') + '</div>' +
|
||||
(it.overview ? '<p class="vdsc-hero-ov">' + esc(it.overview) + '</p>' : '') +
|
||||
'<button class="discog-submit-btn vdsc-hero-cta" type="button" data-vdsc-open ' +
|
||||
'<button class="discog-submit-btn vdsc-hero-cta" type="button" ' +
|
||||
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' +
|
||||
'<span class="discog-submit-text">View ' + (it.kind === 'movie' ? 'movie' : 'show') + ' →</span></button>';
|
||||
}
|
||||
|
||||
function goHero(i) {
|
||||
var items = state.hero.items; if (!items.length) return;
|
||||
state.hero.idx = (i + items.length) % items.length;
|
||||
var slides = document.querySelectorAll('[data-vdsc-slide]');
|
||||
for (var s = 0; s < slides.length; s++)
|
||||
slides[s].classList.toggle('vdsc-slide--on', s === state.hero.idx);
|
||||
for (var s = 0; s < slides.length; s++) slides[s].classList.toggle('vdsc-slide--on', s === state.hero.idx);
|
||||
var dots = document.querySelectorAll('[data-vdsc-go]');
|
||||
for (var d = 0; d < dots.length; d++)
|
||||
dots[d].classList.toggle('vdsc-dot--on', d === state.hero.idx);
|
||||
for (var d = 0; d < dots.length; d++) dots[d].classList.toggle('vdsc-dot--on', d === state.hero.idx);
|
||||
paintHeroBody();
|
||||
}
|
||||
function startHeroTimer() {
|
||||
|
|
@ -171,88 +171,80 @@
|
|||
// ── shelves (lazy rails) ──────────────────────────────────────────────────
|
||||
function renderShelves() {
|
||||
var host = $('[data-vdsc-shelves]'); if (!host) return;
|
||||
var shelves = buildShelfList();
|
||||
host.innerHTML = shelves.map(function (sh) {
|
||||
return '<section class="vdsc-shelf" data-vdsc-q="' + esc(sh.q) + '">' +
|
||||
host.innerHTML = buildShelfList().map(function (sh) {
|
||||
return '<section class="vdsc-shelf" data-vdsc-q="' + esc(sh.q) + '" data-vdsc-title="' + esc(sh.title) + '">' +
|
||||
'<div class="vdsc-shelf-head">' +
|
||||
'<h3 class="vdsc-shelf-title">' + esc(sh.title) + '</h3>' +
|
||||
'<div class="vdsc-shelf-nav">' +
|
||||
'<button class="vdsc-seeall" type="button" data-vdsc-seeall>See all</button>' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="-1" aria-label="Scroll left">‹</button>' +
|
||||
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="1" aria-label="Scroll right">›</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="vdsc-rail" data-vdsc-rail>' +
|
||||
'<div class="vdsc-skel">' + Array(7).join('<div class="vdsc-skel-card"></div>') + '</div>' +
|
||||
'<div class="vdsc-skel">' + Array(8).join('<div class="vdsc-skel-card"></div>') + '</div>' +
|
||||
'</div>' +
|
||||
'</section>';
|
||||
}).join('');
|
||||
observeShelves();
|
||||
}
|
||||
|
||||
function observeShelves() {
|
||||
if (state.io) state.io.disconnect();
|
||||
if (!('IntersectionObserver' in window)) { // no IO → just load all
|
||||
var all = document.querySelectorAll('.vdsc-shelf');
|
||||
for (var i = 0; i < all.length; i++) fillShelf(all[i]);
|
||||
var shelves = document.querySelectorAll('.vdsc-shelf');
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
for (var i = 0; i < shelves.length; i++) fillShelf(shelves[i]);
|
||||
return;
|
||||
}
|
||||
state.io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (en) {
|
||||
if (en.isIntersecting) { state.io.unobserve(en.target); fillShelf(en.target); }
|
||||
});
|
||||
}, { rootMargin: '300px 0px' });
|
||||
var shelves = document.querySelectorAll('.vdsc-shelf');
|
||||
}, { rootMargin: '400px 0px' });
|
||||
for (var j = 0; j < shelves.length; j++) state.io.observe(shelves[j]);
|
||||
}
|
||||
|
||||
function fillShelf(shelf) {
|
||||
if (!shelf || shelf.getAttribute('data-vdsc-loaded')) return;
|
||||
shelf.setAttribute('data-vdsc-loaded', '1');
|
||||
var rail = $('[data-vdsc-rail]', shelf);
|
||||
var q = shelf.getAttribute('data-vdsc-q');
|
||||
fetch(LIST_URL + '?' + q, { headers: { Accept: 'application/json' } })
|
||||
fetch(LIST_URL + '?' + shelf.getAttribute('data-vdsc-q'), { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
var items = (d && d.items) || [];
|
||||
if (!items.length) { shelf.remove(); return; } // drop empty shelves
|
||||
if (!items.length) { shelf.remove(); return; } // drop empty shelves
|
||||
if (rail) { rail.innerHTML = items.map(card).join(''); hydrateGet(rail); }
|
||||
shelf.classList.add('vdsc-shelf--in'); // reveal
|
||||
})
|
||||
.catch(function () { shelf.remove(); });
|
||||
}
|
||||
|
||||
// ── filter / grid mode ────────────────────────────────────────────────────
|
||||
function applyFilter() {
|
||||
// ── category / filter grid (paged) ────────────────────────────────────────
|
||||
function openCategory(title, q) {
|
||||
state.mode = 'grid';
|
||||
state.filter.page = 1;
|
||||
state.cat = { title: title, q: q, page: 1, paginates: !/key=trending/.test(q), busy: false };
|
||||
$('[data-vdsc-shelves]').classList.add('hidden');
|
||||
$('[data-vdsc-grid-wrap]').classList.remove('hidden');
|
||||
var hero = $('[data-vdsc-hero]'); if (hero) hero.classList.add('hidden');
|
||||
var wrap = $('[data-vdsc-grid-wrap]'); wrap.classList.remove('hidden');
|
||||
var ttl = $('[data-vdsc-grid-title]'); if (ttl) ttl.textContent = title;
|
||||
$('[data-vdsc-grid]').innerHTML = '';
|
||||
try { wrap.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { /* ignore */ }
|
||||
loadGrid(true);
|
||||
}
|
||||
function clearFilter() {
|
||||
function closeCategory() {
|
||||
state.mode = 'shelves';
|
||||
$('[data-vdsc-grid-wrap]').classList.add('hidden');
|
||||
$('[data-vdsc-shelves]').classList.remove('hidden');
|
||||
}
|
||||
function gridQuery() {
|
||||
var f = state.filter;
|
||||
var p = ['kind=' + f.kind, 'sort=' + encodeURIComponent(f.sort), 'page=' + f.filterPage];
|
||||
if (f.genre) p.push('genre=' + f.genre);
|
||||
if (f.decade) p.push('decade=' + f.decade);
|
||||
if (f.year) p.push('year=' + encodeURIComponent(f.year));
|
||||
return p.join('&');
|
||||
if (state.hero.items.length) { var h = $('[data-vdsc-hero]'); if (h) h.classList.remove('hidden'); }
|
||||
}
|
||||
function loadGrid(reset) {
|
||||
var f = state.filter;
|
||||
if (f.busy) return;
|
||||
f.busy = true;
|
||||
f.filterPage = f.page;
|
||||
var c = state.cat;
|
||||
if (c.busy) return;
|
||||
c.busy = true;
|
||||
var more = $('[data-vdsc-more]'); if (more) { more.disabled = true; more.textContent = 'Loading…'; }
|
||||
var ld = $('[data-vdsc-grid-loading]'); if (ld && reset) ld.classList.remove('hidden');
|
||||
fetch(LIST_URL + '?' + gridQuery(), { headers: { Accept: 'application/json' } })
|
||||
fetch(LIST_URL + '?' + c.q + '&page=' + c.page, { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
f.busy = false;
|
||||
c.busy = false;
|
||||
if (ld) ld.classList.add('hidden');
|
||||
var items = (d && d.items) || [];
|
||||
var grid = $('[data-vdsc-grid]');
|
||||
|
|
@ -262,20 +254,35 @@
|
|||
if (more) {
|
||||
more.textContent = 'Load more';
|
||||
more.disabled = false;
|
||||
more.classList.toggle('hidden', items.length < 18); // a full page → likely more
|
||||
more.classList.toggle('hidden', !c.paginates || items.length < 18);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
f.busy = false;
|
||||
c.busy = false;
|
||||
if (ld) ld.classList.add('hidden');
|
||||
if (more) { more.textContent = 'Load more'; more.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
var kind = ($('[data-vdsc-f-kind]') || {}).value === 'show' ? 'show' : 'movie';
|
||||
var genre = ($('[data-vdsc-f-genre]') || {}).value || '';
|
||||
var decade = ($('[data-vdsc-f-decade]') || {}).value || '';
|
||||
var sort = ($('[data-vdsc-f-sort]') || {}).value || 'popularity.desc';
|
||||
var q = ['kind=' + kind, 'sort=' + encodeURIComponent(sort)];
|
||||
var bits = [kind === 'show' ? 'Shows' : 'Movies'];
|
||||
if (genre) { q.push('genre=' + genre); var gn = genreName(kind, genre); if (gn) bits.push(gn); }
|
||||
if (decade) { q.push('decade=' + decade); bits.push(decade + 's'); }
|
||||
openCategory(bits.join(' · '), q.join('&'));
|
||||
}
|
||||
function genreName(kind, id) {
|
||||
var list = state.genres[kind] || [];
|
||||
for (var i = 0; i < list.length; i++) if (String(list[i].id) === String(id)) return list[i].name;
|
||||
return '';
|
||||
}
|
||||
function rebuildGenreOptions() {
|
||||
var sel = $('[data-vdsc-f-genre]'); if (!sel) return;
|
||||
var list = state.genres[state.filter.kind] || [];
|
||||
sel.innerHTML = '<option value="">All genres</option>' + list.map(function (g) {
|
||||
var kind = ($('[data-vdsc-f-kind]') || {}).value === 'show' ? 'show' : 'movie';
|
||||
sel.innerHTML = '<option value="">All genres</option>' + (state.genres[kind] || []).map(function (g) {
|
||||
return '<option value="' + g.id + '">' + esc(g.name) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
|
@ -286,85 +293,73 @@
|
|||
state.wired = true;
|
||||
var page = $('[data-video-subpage="' + PAGE_ID + '"]'); if (!page) return;
|
||||
|
||||
// one delegated click for the whole page: cards + hero CTA + arrows + dots
|
||||
page.addEventListener('click', function (e) {
|
||||
var open = e.target.closest('[data-vsr-open]');
|
||||
if (open) {
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
var kind = open.getAttribute('data-vsr-open');
|
||||
var id = parseInt(open.getAttribute('data-vsr-id'), 10);
|
||||
if (isNaN(id)) return;
|
||||
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
|
||||
detail: { kind: kind, id: id, source: open.getAttribute('data-vsr-source') || 'tmdb' },
|
||||
detail: { kind: open.getAttribute('data-vsr-open'), id: id,
|
||||
source: open.getAttribute('data-vsr-source') || 'tmdb' },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
var seeall = e.target.closest('[data-vdsc-seeall]');
|
||||
if (seeall) {
|
||||
var shelf = seeall.closest('.vdsc-shelf');
|
||||
if (shelf) openCategory(shelf.getAttribute('data-vdsc-title'), shelf.getAttribute('data-vdsc-q'));
|
||||
return;
|
||||
}
|
||||
var arrow = e.target.closest('[data-vdsc-scroll]');
|
||||
if (arrow) {
|
||||
var rail = $('[data-vdsc-rail]', arrow.closest('.vdsc-shelf'));
|
||||
if (rail) rail.scrollBy({ left: parseInt(arrow.getAttribute('data-vdsc-scroll'), 10) * rail.clientWidth * 0.8, behavior: 'smooth' });
|
||||
if (rail) rail.scrollBy({ left: parseInt(arrow.getAttribute('data-vdsc-scroll'), 10) * rail.clientWidth * 0.85, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
var dot = e.target.closest('[data-vdsc-go]');
|
||||
if (dot) { goHero(parseInt(dot.getAttribute('data-vdsc-go'), 10)); startHeroTimer(); return; }
|
||||
if (dot) { goHero(parseInt(dot.getAttribute('data-vdsc-go'), 10)); startHeroTimer(); }
|
||||
});
|
||||
|
||||
// hero auto-advance pauses while hovered
|
||||
var hero = $('[data-vdsc-hero]');
|
||||
if (hero) {
|
||||
hero.addEventListener('mouseenter', stopHeroTimer);
|
||||
hero.addEventListener('mouseleave', startHeroTimer);
|
||||
}
|
||||
if (hero) { hero.addEventListener('mouseenter', stopHeroTimer); hero.addEventListener('mouseleave', startHeroTimer); }
|
||||
|
||||
var kind = $('[data-vdsc-f-kind]');
|
||||
if (kind) kind.addEventListener('change', function () {
|
||||
state.filter.kind = kind.value === 'show' ? 'show' : 'movie';
|
||||
rebuildGenreOptions();
|
||||
});
|
||||
var genre = $('[data-vdsc-f-genre]');
|
||||
if (genre) genre.addEventListener('change', function () { state.filter.genre = genre.value; });
|
||||
var decade = $('[data-vdsc-f-decade]');
|
||||
if (decade) decade.addEventListener('change', function () { state.filter.decade = decade.value; });
|
||||
var sort = $('[data-vdsc-f-sort]');
|
||||
if (sort) sort.addEventListener('change', function () { state.filter.sort = sort.value; });
|
||||
var kindSel = $('[data-vdsc-f-kind]');
|
||||
if (kindSel) kindSel.addEventListener('change', rebuildGenreOptions);
|
||||
var apply = $('[data-vdsc-apply]'); if (apply) apply.addEventListener('click', applyFilter);
|
||||
var clear = $('[data-vdsc-clear]'); if (clear) clear.addEventListener('click', closeCategory);
|
||||
var more = $('[data-vdsc-more]'); if (more) more.addEventListener('click', function () { state.cat.page++; loadGrid(false); });
|
||||
|
||||
var apply = $('[data-vdsc-apply]');
|
||||
if (apply) apply.addEventListener('click', applyFilter);
|
||||
var clear = $('[data-vdsc-clear]');
|
||||
if (clear) clear.addEventListener('click', clearFilter);
|
||||
var more = $('[data-vdsc-more]');
|
||||
if (more) more.addEventListener('click', function () { state.filter.page++; loadGrid(false); });
|
||||
var hide = $('[data-vdsc-hideowned]');
|
||||
if (hide) hide.addEventListener('change', function () { page.classList.toggle('vdsc-hide-owned', hide.checked); });
|
||||
}
|
||||
|
||||
function loadGenres() {
|
||||
fetch('/api/video/discover/genres', { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
state.genres = { movie: (d && d.movie) || [], show: (d && d.show) || [] };
|
||||
function loadMeta() {
|
||||
var jget = function (u) { return fetch(u, { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); };
|
||||
Promise.all([jget('/api/video/discover/genres'), jget('/api/video/discover/taste')])
|
||||
.then(function (res) {
|
||||
var g = res[0] || {}, t = res[1] || {};
|
||||
state.genres = { movie: g.movie || [], show: g.show || [] };
|
||||
state.taste = { movie: t.movie || [], show: t.show || [] };
|
||||
rebuildGenreOptions();
|
||||
renderShelves(); // genre rails need the id map
|
||||
})
|
||||
.catch(function () { renderShelves(); });
|
||||
renderShelves();
|
||||
});
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (state.loaded) return;
|
||||
state.loaded = true;
|
||||
loadHero();
|
||||
loadGenres();
|
||||
loadMeta();
|
||||
}
|
||||
|
||||
function onShown(e) { if (e && e.detail === PAGE_ID) { wire(); load(); startHeroTimer(); } }
|
||||
function onHidden() { stopHeroTimer(); }
|
||||
|
||||
function init() {
|
||||
document.addEventListener('soulsync:video-page-shown', onShown);
|
||||
// pause the hero when leaving the page
|
||||
document.addEventListener('soulsync:video-page-shown', function (e) {
|
||||
if (e && e.detail !== PAGE_ID) onHidden();
|
||||
});
|
||||
function onShown(e) {
|
||||
if (!e) return;
|
||||
if (e.detail === PAGE_ID) { wire(); load(); startHeroTimer(); }
|
||||
else stopHeroTimer(); // left the page → stop the slideshow
|
||||
}
|
||||
function init() { document.addEventListener('soulsync:video-page-shown', onShown); }
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
|
|
|
|||
|
|
@ -2258,3 +2258,41 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vdsc-hero-body { animation: none; }
|
||||
.vdsc-skel-card { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Discover v2: ambient glow + see-all + grid head + hide-owned + polish ─── */
|
||||
.vdsc-page { position: relative; }
|
||||
.vdsc-hero, .vdsc-filterbar, .vdsc-shelves, .vdsc-grid-wrap { position: relative; z-index: 1; }
|
||||
/* a faint page-top color bleed that follows the current hero slide's hue */
|
||||
.vdsc-amb { position: absolute; top: -40px; left: 50%; transform: translateX(-50%); z-index: 0;
|
||||
width: 130%; height: 580px; pointer-events: none; opacity: 0.5; transition: opacity 0.7s ease;
|
||||
background: radial-gradient(58% 80% at 50% 0%, hsla(var(--vdsc-amb, 230), 82%, 55%, 0.22), transparent 70%); }
|
||||
|
||||
/* "See all" → opens the rail as a paged grid */
|
||||
.vdsc-seeall { font-size: 12.5px; font-weight: 700; color: rgba(255, 255, 255, 0.5); background: none;
|
||||
border: none; cursor: pointer; padding: 4px 9px; border-radius: 8px; margin-right: 4px;
|
||||
transition: color 0.15s ease, background 0.15s ease; }
|
||||
.vdsc-seeall:hover { color: #fff; background: rgba(255, 255, 255, 0.07); }
|
||||
|
||||
/* grid (category) header — back button + title */
|
||||
.vdsc-grid-head { display: flex; align-items: center; gap: 16px; margin: 2px 0 22px; }
|
||||
.vdsc-grid-title { font-size: 23px; font-weight: 800; letter-spacing: -0.01em; color: #fff; margin: 0; }
|
||||
|
||||
/* hide-owned toggle (sits at the far right of the filter bar) */
|
||||
.vdsc-toggle { display: inline-flex; align-items: center; gap: 7px; margin-left: auto;
|
||||
font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.7); cursor: pointer; user-select: none; }
|
||||
.vdsc-toggle input { accent-color: rgb(var(--accent-rgb, 88 101 242)); width: 16px; height: 16px; cursor: pointer; }
|
||||
.vdsc-hide-owned .vsr-card--owned { display: none; }
|
||||
|
||||
/* rails: soft edge fade + a gentle fade-in when filled + per-title hue on hover */
|
||||
.vdsc-rail { -webkit-mask-image: linear-gradient(90deg, transparent, #000 18px, #000 calc(100% - 18px), transparent);
|
||||
mask-image: linear-gradient(90deg, transparent, #000 18px, #000 calc(100% - 18px), transparent); }
|
||||
.vdsc-shelf--in .vdsc-rail { animation: vdscFade 0.45s ease both; }
|
||||
@keyframes vdscFade { from { opacity: 0.35; } to { opacity: 1; } }
|
||||
.vdsc-rail .vsr-card, .vdsc-grid .vsr-card { transition: transform 0.2s ease, box-shadow 0.2s ease; }
|
||||
.vdsc-rail .vsr-card:hover, .vdsc-grid .vsr-card:hover {
|
||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.5), 0 0 26px -6px hsla(var(--vgm-h, 230), 80%, 58%, 0.5); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vdsc-shelf--in .vdsc-rail { animation: none; }
|
||||
.vdsc-amb { transition: none; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue