diff --git a/api/video/__init__.py b/api/video/__init__.py index f4844fc2..8463725e 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -45,6 +45,7 @@ def create_video_blueprint() -> Blueprint: from .enrichment import register_routes as reg_enrichment from .detail import register_routes as reg_detail from .search import register_routes as reg_search + from .discover import register_routes as reg_discover from .calendar import register_routes as reg_calendar from .watchlist import register_routes as reg_watchlist reg_dashboard(bp) @@ -55,6 +56,7 @@ def create_video_blueprint() -> Blueprint: reg_enrichment(bp) reg_detail(bp) reg_search(bp) + reg_discover(bp) reg_calendar(bp) reg_watchlist(bp) diff --git a/api/video/discover.py b/api/video/discover.py new file mode 100644 index 00000000..747ae520 --- /dev/null +++ b/api/video/discover.py @@ -0,0 +1,74 @@ +"""Video discover API — browse TMDB (movies/TV the user doesn't own yet). + +GET /api/video/discover/hero → trending titles w/ backdrops (slideshow) +GET /api/video/discover/genres → {movie:[{id,name}], show:[{id,name}]} +GET /api/video/discover/list?... → one shelf/grid of items, e.g. + ?key=trending → trending movies + shows + ?key=&page= → a canned list (popular_movies, top_shows…) + ?kind=movie|show&genre=&year=&decade=&sort=&page= → a filtered browse + +Items are annotated with ``library_id`` when already owned (so the card links to +the owned detail, not the TMDB preview). Reads only the enrichment engine + +video.db; isolated from the music API. +""" + +from __future__ import annotations + +from flask import jsonify, request + +from utils.logging_config import get_logger + +logger = get_logger("video_api.discover") + + +def register_routes(bp): + @bp.route("/discover/hero", methods=["GET"]) + def video_discover_hero(): + """A few trending titles that have a backdrop — drives the hero slideshow.""" + from core.video.enrichment.engine import get_video_enrichment_engine + try: + items = [x for x in (get_video_enrichment_engine().trending() or []) + if x.get("backdrop")][:6] + except Exception: + logger.exception("discover hero failed") + items = [] + return jsonify({"items": items}) + + @bp.route("/discover/genres", methods=["GET"]) + def video_discover_genres(): + """Genre id→name maps for both kinds (powers the genre rails + filter).""" + from core.video.enrichment.engine import get_video_enrichment_engine + eng = get_video_enrichment_engine() + try: + return jsonify({"movie": eng.genre_list("movie"), "show": eng.genre_list("show")}) + except Exception: + logger.exception("discover genres failed") + return jsonify({"movie": [], "show": []}) + + @bp.route("/discover/list", methods=["GET"]) + def video_discover_list(): + """One shelf (rail) or one page of a filtered browse — see module docstring.""" + from core.video.enrichment.engine import get_video_enrichment_engine + eng = get_video_enrichment_engine() + try: + page = max(1, int(request.args.get("page", 1) or 1)) + except (TypeError, ValueError): + page = 1 + key = (request.args.get("key") or "").strip() + try: + if key == "trending": + items = eng.trending() + elif key: + items = eng.discover_curated(key, page=page) + else: + items = eng.discover_filter( + request.args.get("kind", "movie"), + genre=(request.args.get("genre") or None), + year=(request.args.get("year") or None), + decade=(request.args.get("decade") or None), + sort_by=(request.args.get("sort") or "popularity.desc"), + page=page) + return jsonify({"items": items or [], "page": page}) + except Exception: + logger.exception("discover list failed (key=%s)", key) + return jsonify({"items": [], "page": page}) diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 7614a3d9..5ac12b78 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -442,29 +442,107 @@ class TMDBClient: return out def trending(self, window="week"): - """Trending movies + shows this week — fills the search page when idle.""" + """Trending movies + shows this week — fills the search page when idle and + the Discover hero slideshow (hence backdrops via _disc_map).""" if not self.api_key: return [] import requests r = requests.get(self.BASE + "/trending/all/" + window, params={"api_key": self.api_key}, timeout=15) r.raise_for_status() + return self._disc_map((r.json() or {}).get("results"), None)[:20] + + # ── discover (browse TMDB by curated list / genre / year / decade) ──────── + def _disc_map(self, results, kind): + """Flatten a TMDB movie/tv list into SoulSync items. ``kind`` forces the + type for single-type endpoints; pass None to auto-detect each row's + media_type (mixed lists like trending). Carries backdrop + overview so the + Discover hero can render a rich slide.""" out = [] - for it in ((r.json() or {}).get("results") or []): - mt, tid = it.get("media_type"), it.get("id") - if not tid or mt not in ("movie", "tv"): + for it in results or []: + tid = it.get("id") + if not tid: continue - if mt == "movie": - out.append({"kind": "movie", "tmdb_id": tid, "title": it.get("title"), - "year": (it.get("release_date") or "")[:4] or None, - "rating": it.get("vote_average") or None, - "poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None}) - else: - out.append({"kind": "show", "tmdb_id": tid, "title": it.get("name"), - "year": (it.get("first_air_date") or "")[:4] or None, - "rating": it.get("vote_average") or None, - "poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None}) - return out[:20] + k = kind + if k is None: + mt = it.get("media_type") + k = "movie" if mt == "movie" else "show" if mt == "tv" else None + if k not in ("movie", "show"): + continue + is_movie = k == "movie" + out.append({ + "kind": k, "tmdb_id": tid, + "title": it.get("title") if is_movie else it.get("name"), + "year": ((it.get("release_date") if is_movie else it.get("first_air_date")) or "")[:4] or None, + "rating": it.get("vote_average") or None, + "overview": it.get("overview") or None, + "poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None, + "backdrop": (self.BACKDROP_W + it["backdrop_path"]) if it.get("backdrop_path") else None, + }) + return out + + # canned TMDB lists → (path, forced kind) + _CURATED = { + "popular_movies": ("/movie/popular", "movie"), + "top_movies": ("/movie/top_rated", "movie"), + "now_playing": ("/movie/now_playing", "movie"), + "upcoming_movies": ("/movie/upcoming", "movie"), + "popular_shows": ("/tv/popular", "show"), + "top_shows": ("/tv/top_rated", "show"), + "on_the_air": ("/tv/on_the_air", "show"), + "airing_today": ("/tv/airing_today", "show"), + } + + def curated(self, key, page=1): + """One of the canned TMDB lists (popular / top-rated / now-playing / …).""" + spec = self._CURATED.get(key) + if not spec or not self.api_key: + return [] + import requests + path, kind = spec + r = requests.get(self.BASE + path, + params={"api_key": self.api_key, "page": page}, timeout=15) + r.raise_for_status() + return self._disc_map((r.json() or {}).get("results"), kind) + + def discover(self, kind, *, genre=None, year=None, decade=None, + sort_by="popularity.desc", page=1): + """Browse /discover/{movie,tv} filtered by genre / year / decade.""" + if not self.api_key: + return [] + import requests + is_movie = kind == "movie" + path = "/discover/movie" if is_movie else "/discover/tv" + params = {"api_key": self.api_key, "sort_by": sort_by, "page": page, + "include_adult": "false", "vote_count.gte": 40} + if genre: + params["with_genres"] = genre + if year: + params["primary_release_year" if is_movie else "first_air_date_year"] = year + if decade: + try: + d0 = int(decade) + gte, lte = "%d-01-01" % d0, "%d-12-31" % (d0 + 9) + if is_movie: + params["primary_release_date.gte"], params["primary_release_date.lte"] = gte, lte + else: + params["first_air_date.gte"], params["first_air_date.lte"] = gte, lte + except (TypeError, ValueError): + pass + r = requests.get(self.BASE + path, params=params, timeout=15) + r.raise_for_status() + return self._disc_map((r.json() or {}).get("results"), kind) + + def genres(self, kind): + """TMDB genre id→name list for movies or shows.""" + if not self.api_key: + return [] + import requests + path = "/genre/movie/list" if kind == "movie" else "/genre/tv/list" + r = requests.get(self.BASE + path, params={"api_key": self.api_key}, timeout=15) + r.raise_for_status() + return [{"id": g["id"], "name": g["name"]} + for g in (r.json() or {}).get("genres") or [] if g.get("id")] def full_detail(self, kind, tmdb_id, region="US"): """Complete detail for a TMDB title NOT in the library — shaped like the diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 362cd11e..c76a44e0 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -305,6 +305,71 @@ class VideoEnrichmentEngine: r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv) return 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.""" + srv = self._server() + 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) + return items + + def discover_curated(self, key, page=1) -> list: + """A canned TMDB list (popular / top-rated / now-playing / upcoming / + on-the-air / airing-today), cached then owned-annotated.""" + w = self.workers.get("tmdb") + if not w or not w.enabled: + return [] + ck = ("disc-cur", key, page) + items = self._cache_get(ck) + if items is None: + try: + items = w.client.curated(key, page=page) or [] + self._cache_put(ck, items, ttl=3600) + except Exception: + logger.exception("discover curated failed (%s p%s)", key, page) + return [] + return self._stamp_owned(items) + + def discover_filter(self, kind, *, genre=None, year=None, decade=None, + sort_by="popularity.desc", page=1) -> list: + """Browse /discover filtered by genre / year / decade, cached + annotated.""" + w = self.workers.get("tmdb") + if not w or not w.enabled: + return [] + if kind not in ("movie", "show"): + kind = "movie" + ck = ("disc-flt", kind, genre, year, decade, sort_by, page) + items = self._cache_get(ck) + if items is None: + try: + items = w.client.discover(kind, genre=genre, year=year, decade=decade, + sort_by=sort_by, page=page) or [] + self._cache_put(ck, items, ttl=3600) + except Exception: + logger.exception("discover filter failed (%s g=%s y=%s d=%s)", kind, genre, year, decade) + return [] + return self._stamp_owned(items) + + def genre_list(self, kind) -> list: + """TMDB genre id→name list (long-cached — these barely change).""" + w = self.workers.get("tmdb") + if not w or not w.enabled: + return [] + if kind not in ("movie", "show"): + kind = "movie" + ck = ("genres", kind) + items = self._cache_get(ck) + if items is None: + try: + items = w.client.genres(kind) or [] + self._cache_put(ck, items, ttl=86400) + except Exception: + logger.exception("genre list failed (%s)", kind) + return [] + return items + def tmdb_detail(self, kind, tmdb_id) -> dict | None: """Full detail for a TMDB title not in the library — same shape as the library detail (source='tmdb', direct image URLs, nothing owned). If it IS diff --git a/webui/index.html b/webui/index.html index 3096cb85..0b22b441 100644 --- a/webui/index.html +++ b/webui/index.html @@ -863,6 +863,57 @@ + + @@ -9571,6 +9622,8 @@ + + diff --git a/webui/static/video/video-discover.js b/webui/static/video/video-discover.js new file mode 100644 index 00000000..074c739c --- /dev/null +++ b/webui/static/video/video-discover.js @@ -0,0 +1,371 @@ +/* + * 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. + */ +(function () { + 'use strict'; + + var PAGE_ID = 'video-discover'; + var LIST_URL = '/api/video/discover/list'; + + var state = { + loaded: false, wired: false, mode: 'shelves', + genres: { movie: [], show: [] }, + io: null, // IntersectionObserver for lazy rails + hero: { items: [], idx: 0, timer: null }, + filter: { kind: 'movie', genre: '', decade: '', year: '', sort: 'popularity.desc', page: 1, busy: false }, + }; + + function $(s, r) { return (r || document).querySelector(s); } + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .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; } + + // ── the rail stack (curated + genre + decade) ───────────────────────────── + var CURATED = [ + { title: 'Trending This Week', q: 'key=trending' }, + { title: 'Popular Movies', q: 'key=popular_movies' }, + { title: 'Popular Shows', q: 'key=popular_shows' }, + { title: 'In Theaters Now', q: 'key=now_playing' }, + { title: 'Coming Soon', q: 'key=upcoming_movies' }, + { title: 'On The Air', q: 'key=on_the_air' }, + { 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 = [ + { title: 'Best of the 2010s', q: 'kind=movie&decade=2010&sort=vote_average.desc' }, + { title: '2000s Favorites', q: 'kind=movie&decade=2000&sort=vote_average.desc' }, + { title: '’90s Classics', q: 'kind=movie&decade=1990&sort=vote_average.desc' }, + { title: 'Retro ’80s', q: 'kind=movie&decade=1980&sort=vote_average.desc' }, + ]; + + 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' }); + }); + return shelves.concat(DECADE_RAILS); + } + + // ── card (mirrors the search title card: owned ribbon + get button) ─────── + function card(it) { + var fallback = it.kind === 'movie' ? '🎬' : '📺'; + var img = it.poster + ? '' + : '
' + fallback + '
'; + var owned = it.library_id != null; + var ribbon = owned + ? 'In Library' + : 'Preview'; + var rating = it.rating + ? '★ ' + (Math.round(it.rating * 10) / 10) + '' : ''; + var source = owned ? 'library' : 'tmdb'; + var id = owned ? it.library_id : it.tmdb_id; + var href = '/video-detail/' + source + '/' + it.kind + '/' + id; + 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 '' + cb + + '
' + img + ribbon + rating + + '
' + + '
' + esc(it.title) + + '' + esc(sub) + '
'; + } + + function hydrateGet(root) { + // shows already on the watchlist paint their eye as "watched" + 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(); + }) + .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 '
' + + '
'; + }).join(''); + var dots = items.map(function (it, i) { + return ''; + }).join(''); + host.innerHTML = + '
' + slides + '
' + + '
' + + '
' + dots + '
'; + 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; + var owned = it.library_id != null; + var source = owned ? 'library' : 'tmdb'; + var id = owned ? it.library_id : it.tmdb_id; + 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)); + body.innerHTML = + '
' + (owned ? 'In your library' : 'Trending now') + '
' + + '

' + esc(it.title) + '

' + + '
' + pills.map(function (p) { + return '' + esc(p) + ''; }).join('') + '
' + + (it.overview ? '

' + esc(it.overview) + '

' : '') + + ''; + } + + 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); + 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); + paintHeroBody(); + } + function startHeroTimer() { + stopHeroTimer(); + if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + if (state.hero.items.length < 2) return; + state.hero.timer = setInterval(function () { goHero(state.hero.idx + 1); }, 6500); + } + function stopHeroTimer() { if (state.hero.timer) { clearInterval(state.hero.timer); state.hero.timer = null; } } + + // ── shelves (lazy rails) ────────────────────────────────────────────────── + function renderShelves() { + var host = $('[data-vdsc-shelves]'); if (!host) return; + var shelves = buildShelfList(); + host.innerHTML = shelves.map(function (sh) { + return '
' + + '
' + + '

' + esc(sh.title) + '

' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + Array(7).join('
') + '
' + + '
' + + '
'; + }).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]); + 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'); + 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' } }) + .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 (rail) { rail.innerHTML = items.map(card).join(''); hydrateGet(rail); } + }) + .catch(function () { shelf.remove(); }); + } + + // ── filter / grid mode ──────────────────────────────────────────────────── + function applyFilter() { + state.mode = 'grid'; + state.filter.page = 1; + $('[data-vdsc-shelves]').classList.add('hidden'); + $('[data-vdsc-grid-wrap]').classList.remove('hidden'); + $('[data-vdsc-grid]').innerHTML = ''; + loadGrid(true); + } + function clearFilter() { + 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('&'); + } + function loadGrid(reset) { + var f = state.filter; + if (f.busy) return; + f.busy = true; + f.filterPage = f.page; + 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' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + f.busy = false; + if (ld) ld.classList.add('hidden'); + var items = (d && d.items) || []; + var grid = $('[data-vdsc-grid]'); + if (grid) { grid.insertAdjacentHTML('beforeend', items.map(card).join('')); hydrateGet(grid); } + var empty = $('[data-vdsc-grid-empty]'); + if (empty) empty.classList.toggle('hidden', !(reset && !items.length)); + if (more) { + more.textContent = 'Load more'; + more.disabled = false; + more.classList.toggle('hidden', items.length < 18); // a full page → likely more + } + }) + .catch(function () { + f.busy = false; + if (ld) ld.classList.add('hidden'); + if (more) { more.textContent = 'Load more'; more.disabled = false; } + }); + } + + function rebuildGenreOptions() { + var sel = $('[data-vdsc-f-genre]'); if (!sel) return; + var list = state.genres[state.filter.kind] || []; + sel.innerHTML = '' + list.map(function (g) { + return ''; + }).join(''); + } + + // ── wiring ──────────────────────────────────────────────────────────────── + function wire() { + if (state.wired) return; + 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' }, + })); + 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' }); + return; + } + var dot = e.target.closest('[data-vdsc-go]'); + if (dot) { goHero(parseInt(dot.getAttribute('data-vdsc-go'), 10)); startHeroTimer(); return; } + }); + + // hero auto-advance pauses while hovered + var hero = $('[data-vdsc-hero]'); + 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 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); }); + } + + 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) || [] }; + rebuildGenreOptions(); + renderShelves(); // genre rails need the id map + }) + .catch(function () { renderShelves(); }); + } + + function load() { + if (state.loaded) return; + state.loaded = true; + loadHero(); + loadGenres(); + } + + 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(); + }); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 6ecdcadc..065903bb 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2158,3 +2158,103 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vgm-overlay.vgm-open .vgm-hero-content > * { animation: none; } .vgm-modal { transition: opacity 0.2s ease; } } + +/* ══════════════════════════════════════════════════════════════════════════ + Discover page — trending hero slideshow + lazy genre/decade rails (.vdsc-*) + ══════════════════════════════════════════════════════════════════════════ */ +.vdsc-page { padding: 0 0 60px; } + +/* ── hero slideshow ───────────────────────────────────────────────────────── */ +.vdsc-hero { position: relative; height: 420px; border-radius: 20px; overflow: hidden; + margin: 4px 0 26px; background: #0d0d12; isolation: isolate; } +.vdsc-hero.hidden { display: none; } +.vdsc-slides { position: absolute; inset: 0; } +.vdsc-slide { position: absolute; inset: 0; background-size: cover; background-position: center 18%; + opacity: 0; transition: opacity 1.1s ease; transform: scale(1.06); } +.vdsc-slide--on { opacity: 1; animation: vdscKen 12s ease-out forwards; } +@keyframes vdscKen { from { transform: scale(1.12); } to { transform: scale(1.0); } } +.vdsc-slide-scrim { position: absolute; inset: 0; + background: linear-gradient(90deg, #0b0b10 6%, rgba(11, 11, 16, 0.72) 38%, rgba(11, 11, 16, 0.15) 70%, transparent), + linear-gradient(0deg, #0b0b10 2%, transparent 42%), + radial-gradient(120% 130% at 0% 100%, hsla(var(--vgm-h, 230), 80%, 50%, 0.34), transparent 58%); } +.vdsc-hero-body { position: absolute; left: 40px; bottom: 34px; z-index: 2; max-width: 560px; + animation: vdscRise 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) both; } +@keyframes vdscRise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } } +.vdsc-hero-eyebrow { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em; + color: hsl(var(--vgm-h, 230), 85%, 78%); margin-bottom: 8px; } +.vdsc-hero-title { font-size: 38px; font-weight: 900; letter-spacing: -0.03em; line-height: 1.04; margin: 0; + color: #fff; text-shadow: 0 3px 26px rgba(0, 0, 0, 0.7), 0 0 46px hsla(var(--vgm-h, 230), 80%, 55%, 0.3); } +.vdsc-hero-pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 13px 0 0; } +.vdsc-hero-pill { font-size: 12px; font-weight: 700; padding: 4px 11px; border-radius: 999px; + background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.9); backdrop-filter: blur(4px); } +.vdsc-hero-ov { font-size: 14px; line-height: 1.6; color: rgba(255, 255, 255, 0.82); margin: 14px 0 0; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.vdsc-hero-cta { margin-top: 18px; + background: linear-gradient(135deg, hsl(var(--vgm-h, 230), 70%, 58%), hsl(var(--vgm-h, 230), 72%, 47%)) !important; + box-shadow: 0 12px 32px hsla(var(--vgm-h, 230), 70%, 45%, 0.5) !important; } +.vdsc-hero-cta:hover { transform: translateY(-2px); filter: brightness(1.07); } +.vdsc-dots { position: absolute; right: 26px; bottom: 26px; z-index: 3; display: flex; gap: 8px; } +.vdsc-dot { width: 9px; height: 9px; border-radius: 50%; border: none; cursor: pointer; padding: 0; + background: rgba(255, 255, 255, 0.32); transition: all 0.2s ease; } +.vdsc-dot--on { background: hsl(var(--vgm-h, 230), 85%, 65%); width: 26px; border-radius: 5px; } + +/* ── filter bar ───────────────────────────────────────────────────────────── */ +.vdsc-filterbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 24px; + padding: 14px 16px; border-radius: 14px; background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.07); } +.vdsc-filter-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.42); margin-right: 2px; } +.vdsc-select { padding: 10px 13px; border-radius: 10px; background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; font-size: 13px; font-weight: 600; + cursor: pointer; outline: none; transition: border-color 0.15s ease; } +.vdsc-select:focus { border-color: rgba(var(--accent-rgb, 88 101 242), 0.6); } +.vdsc-select option { background: #16161d; color: #fff; } +.vdsc-btn { padding: 10px 18px; border-radius: 10px; font-size: 13px; font-weight: 800; cursor: pointer; + border: 1px solid transparent; background: rgb(var(--accent-rgb, 88 101 242)); color: #fff; + box-shadow: 0 8px 22px rgba(var(--accent-rgb, 88 101 242), 0.34); transition: all 0.15s ease; } +.vdsc-btn:hover { filter: brightness(1.08); transform: translateY(-1px); } +.vdsc-btn--ghost { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.82); box-shadow: none; } +.vdsc-btn--ghost:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } + +/* ── shelves (lazy rails) ─────────────────────────────────────────────────── */ +.vdsc-shelves.hidden, .vdsc-grid-wrap.hidden { display: none; } +.vdsc-shelf { margin: 0 0 30px; } +.vdsc-shelf-head { display: flex; align-items: center; justify-content: space-between; margin: 0 0 13px; } +.vdsc-shelf-title { font-size: 19px; font-weight: 800; letter-spacing: -0.01em; color: #fff; margin: 0; } +.vdsc-shelf-nav { display: flex; gap: 7px; } +.vdsc-arrow { width: 32px; height: 32px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); color: rgba(255, 255, 255, 0.8); font-size: 18px; line-height: 1; + cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; } +.vdsc-arrow:hover { background: rgba(var(--accent-rgb, 88 101 242), 0.85); border-color: transparent; color: #fff; } +.vdsc-rail { display: flex; gap: 16px; overflow-x: auto; overflow-y: hidden; padding: 6px 2px 12px; + scroll-snap-type: x proximity; scrollbar-width: thin; } +.vdsc-rail::-webkit-scrollbar { height: 8px; } +.vdsc-rail::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); border-radius: 4px; } +.vdsc-rail::-webkit-scrollbar-track { background: transparent; } +.vdsc-rail .vsr-card { flex: 0 0 162px; width: 162px; scroll-snap-align: start; } + +/* skeleton shimmer while a rail loads */ +.vdsc-skel { display: flex; gap: 16px; } +.vdsc-skel-card { flex: 0 0 162px; height: 280px; border-radius: 12px; + background: linear-gradient(100deg, rgba(255, 255, 255, 0.04) 30%, rgba(255, 255, 255, 0.09) 50%, rgba(255, 255, 255, 0.04) 70%); + background-size: 220% 100%; animation: vdscShimmer 1.4s ease-in-out infinite; } +@keyframes vdscShimmer { from { background-position: 180% 0; } to { background-position: -80% 0; } } + +/* ── filter grid ──────────────────────────────────────────────────────────── */ +.vdsc-grid { margin-top: 4px; } +.vdsc-more-wrap { display: flex; justify-content: center; margin-top: 26px; } +.vdsc-more { padding: 12px 30px; } +.vdsc-more.hidden { display: none; } + +@media (max-width: 720px) { + .vdsc-hero { height: 340px; } + .vdsc-hero-body { left: 22px; bottom: 22px; right: 22px; } + .vdsc-hero-title { font-size: 27px; } + .vdsc-rail .vsr-card, .vdsc-skel-card { flex-basis: 132px; width: 132px; } +} +@media (prefers-reduced-motion: reduce) { + .vdsc-slide, .vdsc-slide--on { animation: none; transition: opacity 0.4s ease; transform: none; } + .vdsc-hero-body { animation: none; } + .vdsc-skel-card { animation: none; } +}