feat(web): rich search rows (#148)

Every search row — library and TMDB alike — gains a w92 poster
thumbnail with a same-size blank fallback, a TMDB rating chip
(hidden when there are no votes), and a trailer chip that resolves
GET /api/trailer on click only, opening the tab inside the handler.
Episode hits carry the series' TMDB id so their chip resolves too.
This commit is contained in:
Miguel Palhas
2026-08-24 01:55:00 +01:00
parent 82b294ab1b
commit 07741c80fe
6 changed files with 288 additions and 41 deletions
+171 -34
View File
@@ -14,6 +14,7 @@ import {
imdbLink,
type MetadataCastMember,
movieMetadata,
resolveTrailer,
rottenTomatoesSearch,
tmdbImage,
tmdbMovieLink,
@@ -628,7 +629,9 @@ function searchMain(
void run(query);
}
} else if (event.key === "ArrowDown") {
const first = refs.deck.querySelector<HTMLElement>(".row-tmdb:not(:disabled)");
const first = refs.deck.querySelector<HTMLElement>(
'.row-tmdb:not(:disabled):not([aria-disabled="true"])',
);
if (first) {
event.preventDefault();
first.focus();
@@ -678,7 +681,11 @@ function searchMain(
return;
}
event.preventDefault();
const rows = [...refs.deck.querySelectorAll<HTMLElement>(".row-tmdb:not(:disabled)")];
const rows = [
...refs.deck.querySelectorAll<HTMLElement>(
'.row-tmdb:not(:disabled):not([aria-disabled="true"])',
),
];
const index = rows.indexOf(target);
if (event.key === "ArrowUp" && index === 0) {
input.focus();
@@ -727,6 +734,131 @@ function seasonTag(seasonNumber: number): string {
return `S${two(seasonNumber)}`;
}
/* ---- §9.2 rich-row parts (#148) ---------------------------------------- */
/** A search row's poster at w92, or a same-size blank so the edge never jitters. */
function rowPoster(posterPath: string | null, title: string): HTMLElement {
if (posterPath === null) {
const blank = document.createElement("span");
blank.className = "row-poster-blank";
blank.setAttribute("aria-hidden", "true");
return blank;
}
const img = document.createElement("img");
img.className = "row-poster";
img.loading = "lazy";
img.src = tmdbImage(posterPath, "w92") ?? "";
img.alt = `${title} poster`;
// hotlinked art can 404; fall back to the blank rather than alt-text spill
img.addEventListener("error", () => {
const blank = document.createElement("span");
blank.className = "row-poster-blank";
blank.setAttribute("aria-hidden", "true");
img.replaceWith(blank);
});
return img;
}
/**
* TMDB's rating as a chip (#148). No votes means no chip — never a 0.0.
* `vote_count` rides along as the title when the response carries one.
*/
function ratingChip(voteAverage: number | null, voteCount?: number): HTMLSpanElement | null {
if (voteAverage === null) {
return null;
}
const span = document.createElement("span");
span.className = "chip";
span.append(starIcon(), document.createTextNode(formatRating(voteAverage)));
if (voteCount !== undefined) {
span.title = `${formatVoteCount(voteCount)} votes`;
}
return span;
}
/**
* The §9.6 trailer chip: one call, fired from the click and never before,
* so a page of results costs zero trailer requests. The tab opens inside
* the handler — an async `window.open` is what popup blockers eat — and is
* navigated when the key arrives; no video closes it again quietly.
*/
function trailerChip(kind: "movie" | "tv", tmdbId: number): HTMLSpanElement {
const chipEl = document.createElement("span");
chipEl.className = "chip row-trailer";
chipEl.textContent = "trailer";
chipEl.setAttribute("role", "button");
chipEl.tabIndex = 0;
let busy = false;
const resolve = () => {
if (busy || chipEl.dataset.state !== undefined) {
return;
}
busy = true;
const tab = window.open("", "_blank");
chipEl.dataset.state = "pending";
chipEl.textContent = "trailer…";
void resolveTrailer(kind, tmdbId).then((outcome) => {
busy = false;
if (outcome.kind === "trailer") {
if (tab) {
tab.location.href = youtubeLink(outcome.youtubeKey);
delete chipEl.dataset.state;
chipEl.textContent = "trailer";
} else {
// visibly blocked beats silently swallowed
chipEl.dataset.state = "blocked";
chipEl.textContent = "popups blocked";
}
return;
}
tab?.close();
if (outcome.kind === "none") {
// TMDB having no video is an ordinary outcome, not a failure
chipEl.dataset.state = "none";
chipEl.textContent = "no trailer";
chipEl.removeAttribute("role");
chipEl.removeAttribute("tabindex");
} else {
delete chipEl.dataset.state;
chipEl.title = outcome.detail;
chipEl.textContent = "trailer";
}
});
};
chipEl.addEventListener("click", (event) => {
event.stopPropagation();
event.preventDefault();
resolve();
});
chipEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.stopPropagation();
event.preventDefault();
resolve();
}
});
return chipEl;
}
/** Poster left, then title / chips / overview in a wrapping column (#148). */
function richRow(): {
item: HTMLLIElement;
row: HTMLButtonElement;
body: HTMLElement;
chips: HTMLElement;
} {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb row-rich";
const body = document.createElement("span");
body.className = "row-main";
const chips = document.createElement("span");
chips.className = "row-chips";
return { item, row, body, chips };
}
function episodeTag(seasonNumber: number, episodeNumber: number): string {
return `${seasonTag(seasonNumber)}E${two(episodeNumber)}`;
}
@@ -740,12 +872,7 @@ function libraryRow(
roots: Root[],
open: (movie: LibraryMovie, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const { item, row, body, chips } = richRow();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
chips.append(
@@ -768,11 +895,17 @@ function libraryRow(
if (movie.blocked) {
chips.append(chip("blocked"));
}
const rating = ratingChip(movie.vote_average);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip("movie", movie.tmdb_id));
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "open";
chips.append(affordance);
row.append(rowTitle(movie.title, movie.year), chips);
body.append(rowTitle(movie.title, movie.year), chips);
row.append(rowPoster(movie.poster_path, movie.title), body);
row.addEventListener("click", () => {
open(movie, row);
});
@@ -789,22 +922,23 @@ function librarySeriesRow(
roots: Root[],
open: (id: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const { item, row, body, chips } = richRow();
const root = roots.find((candidate) => candidate.id === series.root_id);
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
if (series.blocked) {
chips.append(chip("blocked"));
}
const rating = ratingChip(series.vote_average);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip("tv", series.tmdb_id));
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "episodes";
chips.append(affordance);
row.append(rowTitle(series.title, series.year), chips);
body.append(rowTitle(series.title, series.year), chips);
row.append(rowPoster(series.poster_path, series.title), body);
row.addEventListener("click", () => {
open(series.id, row);
});
@@ -817,12 +951,7 @@ function episodeRow(
episode: LibraryEpisodeHit,
open: (id: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const { item, row, body, chips } = richRow();
chips.append(
chip(episode.tag, (span) => {
span.setAttribute("aria-label", `${episode.series_title} ${episode.tag}, ${episode.title}`);
@@ -833,11 +962,17 @@ function episodeRow(
title.className = "row-sub";
title.textContent = episode.title;
chips.append(title);
const rating = ratingChip(episode.vote_average);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip("tv", episode.series_tmdb_id));
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "episodes";
chips.append(affordance);
row.append(rowTitle(episode.series_title, null), chips);
body.append(rowTitle(episode.series_title, null), chips);
row.append(rowPoster(episode.poster_path, episode.series_title), body);
row.addEventListener("click", () => {
open(episode.series_id, row);
});
@@ -851,13 +986,7 @@ function tmdbRow(
inLibrary: boolean,
fetchRoots: () => Promise<Root[]>,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const { item, row, body, chips } = richRow();
chips.append(chip(hit.original_language));
if (inLibrary) {
chips.append(
@@ -871,17 +1000,25 @@ function tmdbRow(
add.textContent = "add";
chips.append(add);
}
row.append(rowTitle(hit.title, hit.year), chips);
const rating = ratingChip(hit.vote_average, hit.vote_count);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip(hit.kind === "series" ? "tv" : "movie", hit.tmdb_id));
body.append(rowTitle(hit.title, hit.year), chips);
if (hit.overview) {
const overview = document.createElement("span");
overview.className = "row-overview";
overview.textContent = hit.overview;
row.append(overview);
body.append(overview);
}
row.append(rowPoster(hit.poster_path, hit.title), body);
item.append(row);
if (inLibrary) {
row.disabled = true;
// aria-disabled, not disabled: a dead button would also kill the
// trailer chip's clicks, and that chip stays live on every row
row.setAttribute("aria-disabled", "true");
return item;
}
@@ -1037,7 +1174,7 @@ function addPanel(
: `added to ${root.audience} — future seasons ${autoTrack ? "" : "not "}tracked automatically`;
panel.replaceChildren(done);
done.focus();
row.disabled = true;
row.setAttribute("aria-disabled", "true");
row.setAttribute("aria-expanded", "true");
row.querySelector(".row-add")?.replaceWith(
chip("in library", (span) => {