video: smart multi-layer back button + next-level search/person

Smart back (mirrors music's artist-detail): the top-left back button now
remembers where you actually came from, many layers deep. It keeps an origin
stack ({page} or {detail title}) and stamps each history entry with its layer
depth, so:
- the label is dynamic — '← Back to Search' / '← Back to The Bear' / '← Back to
  <person>' — instead of a hardcoded 'Library'/'Back';
- backing out of the first layer returns to the page you started from (Search,
  Watchlist, wherever), not always the Library;
- browser Back and our button both unwind the chain one layer at a time, in sync.
Fixes: search → person → back → movie used to mislabel as 'Library' and dump you
in the library.

Next level:
- Search isn't a blank box when idle — a 'Trending this week' rail (TMDB
  trending, owned/preview annotated). Returns when you clear the query.
- Person page gets a 'Known For' hero rail (top titles by popularity) above a
  full filmography now sorted chronologically (newest first).

Backend: TMDBClient.trending + engine.trending (+library annotation), route
/api/video/trending. Isolated; 237 video-suite tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-15 08:30:17 -07:00
parent 87f414c8c7
commit d6d0dc537c
11 changed files with 231 additions and 19 deletions

View file

@ -31,3 +31,13 @@ def register_routes(bp):
logger.exception("video search failed for %r", q) logger.exception("video search failed for %r", q)
results = [] results = []
return jsonify({"results": results, "query": q}) return jsonify({"results": results, "query": q})
@bp.route("/trending", methods=["GET"])
def video_trending():
try:
from core.video.enrichment.engine import get_video_enrichment_engine
results = get_video_enrichment_engine().trending()
except Exception:
logger.exception("video trending failed")
results = []
return jsonify({"results": results})

View file

@ -272,6 +272,31 @@ class TMDBClient:
"poster": (self.PROFILE + it["profile_path"]) if it.get("profile_path") else None}) "poster": (self.PROFILE + it["profile_path"]) if it.get("profile_path") else None})
return out return out
def trending(self, window="week"):
"""Trending movies + shows this week — fills the search page when idle."""
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()
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"):
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]
def full_detail(self, kind, tmdb_id): def full_detail(self, kind, tmdb_id):
"""Complete detail for a TMDB title NOT in the library — shaped like the """Complete detail for a TMDB title NOT in the library — shaped like the
library detail payload but with direct image URLs (so the same detail UI library detail payload but with direct image URLs (so the same detail UI

View file

@ -182,6 +182,21 @@ class VideoEnrichmentEngine:
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"]) r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"])
return results return results
def trending(self) -> list:
"""Trending titles for the idle search page, annotated owned/not."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return []
try:
results = w.client.trending() or []
except Exception:
logger.exception("video trending failed")
return []
for r in results:
if r.get("tmdb_id"):
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"])
return results
def tmdb_detail(self, kind, tmdb_id) -> dict | None: def tmdb_detail(self, kind, tmdb_id) -> dict | None:
"""Full detail for a TMDB title not in the library — same shape as the """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 library detail (source='tmdb', direct image URLs, nothing owned). If it IS

View file

@ -46,6 +46,7 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/detail/movie/<int:movie_id>/refresh-art" in rules assert "/api/video/detail/movie/<int:movie_id>/refresh-art" in rules
assert "/api/video/detail/<kind>/<int:item_id>/extras" in rules assert "/api/video/detail/<kind>/<int:item_id>/extras" in rules
assert "/api/video/search" in rules assert "/api/video/search" in rules
assert "/api/video/trending" in rules
assert "/api/video/tmdb/<kind>/<int:tmdb_id>" in rules assert "/api/video/tmdb/<kind>/<int:tmdb_id>" in rules
assert "/api/video/tmdb/show/<int:tv_id>/season/<int:season_number>" in rules assert "/api/video/tmdb/show/<int:tv_id>/season/<int:season_number>" in rules
assert "/api/video/person/<int:tmdb_id>" in rules assert "/api/video/person/<int:tmdb_id>" in rules

View file

@ -256,6 +256,30 @@ def test_engine_search_annotates_library(db):
assert "library_id" not in res[2] # people aren't library-matched assert "library_id" not in res[2] # people aren't library-matched
def test_engine_trending_annotates_library(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
class Tmdb:
enabled = True
def trending(self):
return [{"kind": "movie", "tmdb_id": 1, "title": "Owned"},
{"kind": "show", "tmdb_id": 2, "title": "Hot show"}]
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
res = eng.trending()
assert res[0]["library_id"] == mid and res[1]["library_id"] is None
def test_tmdb_trending_parses(monkeypatch):
body = {"results": [
{"media_type": "movie", "id": 1, "title": "A", "release_date": "2024-01-01", "poster_path": "/a.jpg"},
{"media_type": "tv", "id": 2, "name": "B", "first_air_date": "2023-05-05"},
{"media_type": "person", "id": 3, "name": "C"}]} # people excluded from the rail
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
res = TMDBClient("KEY").trending()
assert [r["kind"] for r in res] == ["movie", "show"]
assert res[0]["poster"] == "https://image.tmdb.org/t/p/w300/a.jpg"
def test_engine_person_detail_annotates_credits(db): def test_engine_person_detail_annotates_credits(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1}) mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})

View file

@ -394,6 +394,7 @@ def test_search_subpage_and_module():
assert "(function" in src and "})();" in src assert "(function" in src and "})();" in src
assert "window." not in src # no globals assert "window." not in src # no globals
assert "/api/video/search" in src assert "/api/video/search" in src
assert "/api/video/trending" in src # idle page shows a trending rail
assert "soulsync:video-open-detail" in src # results drill in via the shared event assert "soulsync:video-open-detail" in src # results drill in via the shared event
assert "themoviedb.org" not in src and "imdb.com" not in src # stays in-app assert "themoviedb.org" not in src and "imdb.com" not in src # stays in-app
@ -432,6 +433,18 @@ def test_video_side_registers_detail_pages_and_open_event():
assert "soulsync:video-open-detail" in _JS # navigates on the event assert "soulsync:video-open-detail" in _JS # navigates on the event
def test_smart_back_button_remembers_origin():
# The back button label is dynamic (a span we rewrite), and the JS keeps an
# origin stack + layer-stamped history so back returns to where you came from
# (not always the library).
assert _INDEX.count("data-vd-back-label") >= 3 # all three detail back buttons
assert "_backStack" in _JS and "currentOrigin" in _JS
assert "updateBackLabels" in _JS
assert "layer: _backStack.length" in _JS # depth stamped on history state
# Fallback must use the recorded first origin, not a hardcoded library jump.
assert "_backStack[0]" in _JS
def test_music_worker_orbs_untouched_by_video(): def test_music_worker_orbs_untouched_by_video():
# The video orbs are a separate file; the music orbs must not learn about # The video orbs are a separate file; the music orbs must not learn about
# the video side (one-way isolation — music never depends on video). # the video side (one-way isolation — music never depends on video).

View file

@ -871,7 +871,7 @@
at load for a per-show glow. Built by video/video-detail.js. --> at load for a per-show glow. Built by video/video-detail.js. -->
<div class="vd-page" data-video-detail="show"> <div class="vd-page" data-video-detail="show">
<button class="vd-back" type="button" data-video-detail-back> <button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> Library <span aria-hidden="true">&larr;</span> <span data-vd-back-label>Back</span>
</button> </button>
<!-- Billboard: full-bleed backdrop, content anchored bottom-left. --> <!-- Billboard: full-bleed backdrop, content anchored bottom-left. -->
<div class="vd-billboard"> <div class="vd-billboard">
@ -928,7 +928,7 @@
<section class="video-subpage" data-video-subpage="video-movie-detail" hidden> <section class="video-subpage" data-video-subpage="video-movie-detail" hidden>
<div class="vd-page" data-video-detail="movie"> <div class="vd-page" data-video-detail="movie">
<button class="vd-back" type="button" data-video-detail-back> <button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> Library <span aria-hidden="true">&larr;</span> <span data-vd-back-label>Back</span>
</button> </button>
<div class="vd-billboard"> <div class="vd-billboard">
<div class="vd-bb-bg" data-vd-backdrop aria-hidden="true"></div> <div class="vd-bb-bg" data-vd-backdrop aria-hidden="true"></div>
@ -1027,7 +1027,7 @@
<div class="vp-page" data-video-person data-has-bg="0"> <div class="vp-page" data-video-person data-has-bg="0">
<div class="vp-ambient" data-vp-ambient aria-hidden="true"></div> <div class="vp-ambient" data-vp-ambient aria-hidden="true"></div>
<button class="vd-back" type="button" data-video-detail-back> <button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> Back <span aria-hidden="true">&larr;</span> <span data-vd-back-label>Back</span>
</button> </button>
<div class="vd-loading" data-vp-loading hidden> <div class="vd-loading" data-vp-loading hidden>
<div class="loading-spinner"></div><div class="loading-text">Loading&hellip;</div> <div class="loading-spinner"></div><div class="loading-text">Loading&hellip;</div>
@ -1043,6 +1043,10 @@
<div class="vp-body"> <div class="vp-body">
<p class="vp-bio" data-vp-bio></p> <p class="vp-bio" data-vp-bio></p>
<button class="vp-bio-more" type="button" data-vp-bio-more hidden>Read more</button> <button class="vp-bio-more" type="button" data-vp-bio-more hidden>Read more</button>
<div class="vp-known-section" data-vp-known-section hidden>
<h2 class="vp-credits-title vp-known-title">Known For</h2>
<div class="vp-known-rail" data-vp-known></div>
</div>
<div class="vp-credits-section"> <div class="vp-credits-section">
<div class="vp-credits-head"><h2 class="vp-credits-title">Filmography</h2> <div class="vp-credits-head"><h2 class="vp-credits-title">Filmography</h2>
<div class="vp-credit-tabs" data-vp-tabs></div></div> <div class="vp-credit-tabs" data-vp-tabs></div></div>

View file

@ -63,12 +63,24 @@
}).join(''); }).join('');
} }
function renderKnownFor() {
var section = q('[data-vp-known-section]'), host = q('[data-vp-known]');
if (!section || !host || !data) return;
// The credits arrive popularity-sorted → the top few are the "known for".
var top = (data.credits || []).slice(0, 10);
section.hidden = top.length < 3; // only worth a rail if there are a few
host.innerHTML = top.map(creditCard).join('');
}
function renderCredits() { function renderCredits() {
var host = q('[data-vp-credits]'); var host = q('[data-vp-credits]');
if (!host || !data) return; if (!host || !data) return;
var credits = (data.credits || []).filter(function (c) { var credits = (data.credits || []).filter(function (c) {
return tab === 'all' || c.kind === tab; return tab === 'all' || c.kind === tab;
}); });
// Full filmography reads best chronologically (newest first); Known For
// already covers the popular ones.
credits.sort(function (a, b) { return (b.date || '').localeCompare(a.date || ''); });
host.innerHTML = credits.map(creditCard).join(''); host.innerHTML = credits.map(creditCard).join('');
} }
@ -105,7 +117,7 @@
if (bio) { bio.textContent = d.biography || ''; bio.hidden = !d.biography; bio.classList.remove('vp-bio--open'); } if (bio) { bio.textContent = d.biography || ''; bio.hidden = !d.biography; bio.classList.remove('vp-bio--open'); }
if (more) { more.hidden = !((d.biography || '').length > 320); more.textContent = 'Read more'; } if (more) { more.hidden = !((d.biography || '').length > 320); more.textContent = 'Read more'; }
renderTabs(); renderCredits(); renderKnownFor(); renderTabs(); renderCredits();
var sub = document.querySelector('.video-subpage[data-video-subpage="video-person-detail"]'); var sub = document.querySelector('.video-subpage[data-video-subpage="video-person-detail"]');
if (sub) sub.scrollTop = 0; if (sub) sub.scrollTop = 0;
} }
@ -118,6 +130,8 @@
var m = q('[data-vp-meta]'); if (m) m.innerHTML = ''; var m = q('[data-vp-meta]'); if (m) m.innerHTML = '';
var c = q('[data-vp-credits]'); if (c) c.innerHTML = ''; var c = q('[data-vp-credits]'); if (c) c.innerHTML = '';
var t = q('[data-vp-tabs]'); if (t) t.innerHTML = ''; var t = q('[data-vp-tabs]'); if (t) t.innerHTML = '';
var ks = q('[data-vp-known-section]'); if (ks) ks.hidden = true;
var k = q('[data-vp-known]'); if (k) k.innerHTML = '';
fetch(PERSON_URL + id, { headers: { 'Accept': 'application/json' } }) fetch(PERSON_URL + id, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; }) .then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { .then(function (d) {

View file

@ -24,6 +24,7 @@
var reqSeq = 0; // guards against out-of-order responses var reqSeq = 0; // guards against out-of-order responses
var timer = null; var timer = null;
var wired = false; var wired = false;
var trendingCache = null; // null = not fetched; [] = fetched/empty
function $(sel) { return document.querySelector(sel); } function $(sel) { return document.querySelector(sel); }
function esc(s) { function esc(s) {
@ -94,6 +95,34 @@
host.innerHTML = html; host.innerHTML = html;
} }
// Idle state: a "Trending this week" rail so the page isn't a blank box.
function renderTrending() {
var host = $('[data-video-search-results]');
if (!host || !trendingCache || !trendingCache.length) return;
show('[data-video-search-hint]', false);
show('[data-video-search-empty]', false);
host.innerHTML = '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">🔥</span>Trending this week</h2>' +
'<div class="vsr-grid">' + trendingCache.map(titleCard).join('') + '</div></div>';
}
function loadTrending() {
if (trendingCache !== null) { if (!lastQuery) renderTrending(); return; }
fetch('/api/video/trending', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
trendingCache = (d && d.results) ? d.results : [];
if (!lastQuery) renderTrending();
})
.catch(function () { trendingCache = []; });
}
function showIdle() {
if (trendingCache && trendingCache.length) { renderTrending(); return; }
show('[data-video-search-empty]', false);
show('[data-video-search-hint]', true);
var host = $('[data-video-search-results]'); if (host) host.innerHTML = '';
loadTrending();
}
function runSearch(q) { function runSearch(q) {
var seq = ++reqSeq; var seq = ++reqSeq;
show('[data-video-search-loading]', true); show('[data-video-search-loading]', true);
@ -118,9 +147,7 @@
if (!q) { if (!q) {
reqSeq++; // cancel any in-flight render reqSeq++; // cancel any in-flight render
show('[data-video-search-loading]', false); show('[data-video-search-loading]', false);
show('[data-video-search-empty]', false); showIdle(); // back to the trending rail
show('[data-video-search-hint]', true);
var host = $('[data-video-search-results]'); if (host) host.innerHTML = '';
return; return;
} }
timer = setTimeout(function () { runSearch(q); }, 320); timer = setTimeout(function () { runSearch(q); }, 320);
@ -162,6 +189,7 @@
wire(); wire();
var input = $('[data-video-search-input]'); var input = $('[data-video-search-input]');
if (input) { try { input.focus(); } catch (err) { /* ignore */ } } if (input) { try { input.focus(); } catch (err) { /* ignore */ } }
if (!lastQuery) loadTrending(); // fill the idle page
} }
function init() { function init() {

View file

@ -454,6 +454,10 @@ body[data-side="video"] .dashboard-header-sweep {
backdrop-filter: blur(10px); transition: all 0.2s ease; backdrop-filter: blur(10px); transition: all 0.2s ease;
} }
.vd-back:hover { background: rgba(0,0,0,0.7); transform: translateX(-2px); } .vd-back:hover { background: rgba(0,0,0,0.7); transform: translateX(-2px); }
.vd-back [data-vd-back-label] {
display: inline-block; max-width: 230px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; vertical-align: bottom;
}
/* ── Billboard ─────────────────────────────────────────────────────────────── */ /* ── Billboard ─────────────────────────────────────────────────────────────── */
.vd-billboard { position: relative; width: 100%; min-height: 76vh; display: flex; align-items: flex-end; overflow: hidden; } .vd-billboard { position: relative; width: 100%; min-height: 76vh; display: flex; align-items: flex-end; overflow: hidden; }
@ -1023,6 +1027,16 @@ body[data-side="video"] .dashboard-header-sweep {
} }
.vp-bio-more[hidden] { display: none; } .vp-bio-more[hidden] { display: none; }
/* Known For — a horizontal hero rail of the person's best-known titles */
.vp-known-section { position: relative; z-index: 2; margin-bottom: 38px; }
.vp-known-title { margin: 0 0 16px; }
.vp-known-rail {
display: flex; gap: 18px; overflow-x: auto; padding: 4px 2px 14px; scroll-snap-type: x proximity;
}
.vp-known-rail::-webkit-scrollbar { height: 8px; }
.vp-known-rail::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
.vp-known-rail .vsr-card { flex: 0 0 152px; width: 152px; scroll-snap-align: start; }
.vp-credits-section { position: relative; z-index: 2; } .vp-credits-section { position: relative; z-index: 2; }
.vp-credits-head { .vp-credits-head {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; margin-bottom: 20px;

View file

@ -50,14 +50,68 @@
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
{ detail: { kind: r.kind, id: r.id, source: r.source, _restore: true } })); { detail: { kind: r.kind, id: r.id, source: r.source, _restore: true } }));
} }
// ── Smart back button (mirrors music's artist-detail) ─────────────────────
// Browser history does the real (multi-layer) navigation; this stack only
// tracks WHERE EACH detail layer was opened FROM, so the button can label
// itself ("← Back to Search" / "← Back to The Bear") and so backing out of
// the first layer returns to the right page — not always the library. Each
// entry: {type:'page', pageId} or {type:'detail', label}. The pushed history
// state carries its layer depth so browser Back stays in sync too.
var _backStack = [];
function detailTitleOf(pageId) {
if (pageId === 'video-person-detail') {
var n = document.querySelector('[data-video-person] [data-vp-name]');
return n ? (n.textContent || '').trim() : '';
}
var host = pageId === 'video-movie-detail' ? '[data-video-detail="movie"]' : '[data-video-detail="show"]';
var t = document.querySelector(host + ' [data-vd-title]');
return t ? (t.textContent || '').trim() : '';
}
function currentOrigin() {
var page = document.body.getAttribute('data-video-page');
if (DETAIL_PAGES[page]) return { type: 'detail', label: detailTitleOf(page) };
return { type: 'page', pageId: page };
}
function backLabelText() {
var top = _backStack[_backStack.length - 1];
if (!top) return 'Back';
if (top.type === 'detail') return top.label ? ('Back to ' + top.label) : 'Back';
return 'Back to ' + pageMeta(top.pageId).label;
}
function updateBackLabels() {
var text = backLabelText();
var labels = document.querySelectorAll('[data-vd-back-label]');
for (var i = 0; i < labels.length; i++) labels[i].textContent = text;
}
function backFallback() {
// No browser history to pop (deep link) — go to the recorded first origin.
var dest = (_backStack[0] && _backStack[0].type === 'page') ? _backStack[0].pageId : 'video-library';
_backStack = [];
navigate(dest);
updateBackLabels();
}
function onPopState() { function onPopState() {
var r = parseDetailPath(window.location.pathname); var r = parseDetailPath(window.location.pathname);
if (r) { restoreDetail(r); return; } if (r) {
// Left the detail URL — if we're still on the video side showing a detail, // Synced to the layer depth stamped on the history entry (handles both
// fall back to the library (Back out of the detail). // our back button and the browser's Back).
var st = window.history.state;
var layer = (st && st.videoDetail && st.videoDetail.layer) || _backStack.length;
if (_backStack.length > layer) _backStack.length = layer;
restoreDetail(r);
updateBackLabels();
return;
}
// Left the detail URL — return to where the chain started, not always library.
if (document.body.getAttribute('data-side') === 'video' && if (document.body.getAttribute('data-side') === 'video' &&
DETAIL_PAGES[document.body.getAttribute('data-video-page')]) { DETAIL_PAGES[document.body.getAttribute('data-video-page')]) {
navigate('video-library'); var dest = (_backStack[0] && _backStack[0].type === 'page') ? _backStack[0].pageId : 'video-library';
_backStack = [];
navigate(dest);
updateBackLabels();
} }
} }
@ -220,6 +274,7 @@
(function (btn) { (function (btn) {
btn.addEventListener('click', function (e) { btn.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
_backStack = []; // sidebar nav is a fresh entry point
navigate(btn.getAttribute('data-video-page')); navigate(btn.getAttribute('data-video-page'));
}); });
})(navButtons[j]); })(navButtons[j]);
@ -232,6 +287,7 @@
(function (el) { (function (el) {
el.addEventListener('click', function (e) { el.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
_backStack = []; // in-page jump is a fresh entry point
navigate(el.getAttribute('data-video-goto')); navigate(el.getAttribute('data-video-goto'));
}); });
})(gotos[k]); })(gotos[k]);
@ -242,27 +298,35 @@
// and push a real URL — unless we're restoring from the URL (_restore). // and push a real URL — unless we're restoring from the URL (_restore).
document.addEventListener('soulsync:video-open-detail', function (e) { document.addEventListener('soulsync:video-open-detail', function (e) {
var d = e && e.detail; if (!d) return; var d = e && e.detail; if (!d) return;
// Capture the origin BEFORE navigating away from the current page.
var origin = d._restore ? null : currentOrigin();
if (d.kind === 'movie') navigate('video-movie-detail'); if (d.kind === 'movie') navigate('video-movie-detail');
else if (d.kind === 'show') navigate('video-show-detail'); else if (d.kind === 'show') navigate('video-show-detail');
else if (d.kind === 'person') navigate('video-person-detail'); else if (d.kind === 'person') navigate('video-person-detail');
else return; else return;
if (!d._restore) { if (!d._restore) {
_backStack.push(origin);
var state = { videoDetail: { kind: d.kind, id: d.id, source: d.source || 'library',
layer: _backStack.length } };
var path = buildDetailPath(d.source, d.kind, d.id); var path = buildDetailPath(d.source, d.kind, d.id);
if (window.location.pathname !== path) { if (window.location.pathname !== path) history.pushState(state, '', path);
history.pushState({ videoDetail: { kind: d.kind, id: d.id, source: d.source || 'library' } }, else history.replaceState(state, '', path);
'', path);
}
} }
updateBackLabels();
}); });
// The detail "← Library" back button is real Back (returns to wherever you // The detail back button is real browser Back (so it unwinds the whole
// drilled in from); falls back to the library if there's no in-app history. // drill-in chain, layer by layer); only when there's no in-app history to
// pop does it fall back to the recorded first origin.
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var back = e.target.closest('[data-video-detail-back]'); var back = e.target.closest('[data-video-detail-back]');
if (!back) return; if (!back) return;
e.preventDefault(); e.preventDefault();
if (history.state && history.state.videoDetail) history.back(); if (window.history.length > 1 && window.history.state && window.history.state.videoDetail) {
else navigate('video-library'); window.history.back();
} else {
backFallback();
}
}); });
window.addEventListener('popstate', onPopState); window.addEventListener('popstate', onPopState);