Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 84 additions & 3 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,8 @@ function renderCard() {
$("card-overview").textContent = card.overview || "";
$("trailer-btn").classList.remove("hidden");
$("trailer-btn").onclick = () => openTrailer(card.tmdb_id);
$("details-btn").classList.remove("hidden");
$("details-btn").onclick = () => openDetails(card);
renderRatings(card.tmdb_id, "card-rating");
}

Expand Down Expand Up @@ -541,15 +543,16 @@ function setupGestures() {
const el = $("card");
let drag = null;
el.addEventListener("pointerdown", (e) => {
if (el.classList.contains("hidden") || e.target.closest("#trailer-btn")) return;
drag = { x: e.clientX, y: e.clientY, dx: 0 };
if (el.classList.contains("hidden") || e.target.closest("button")) return;
drag = { x: e.clientX, y: e.clientY, dx: 0, moved: false };
el.setPointerCapture(e.pointerId);
el.style.transition = "none";
});
el.addEventListener("pointermove", (e) => {
if (!drag) return;
drag.dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
if (!drag.moved && Math.hypot(drag.dx, dy) > 8) drag.moved = true;
const rot = Math.max(-15, Math.min(15, drag.dx / 12));
el.style.transform = `translate(${drag.dx}px, ${dy}px) rotate(${rot}deg)`;
const p = Math.min(1, Math.abs(drag.dx) / SWIPE_THRESHOLD);
Expand All @@ -558,8 +561,18 @@ function setupGestures() {
});
const end = () => {
if (!drag) return;
const dx = drag.dx;
const { dx, moved } = drag;
drag = null;
if (!moved) {
// A tap (no real drag) → open the details sheet for the current card.
el.style.transition = "transform 0.15s ease";
el.style.transform = "";
$("stamp-like").style.opacity = 0;
$("stamp-nope").style.opacity = 0;
const card = state.deck[state.deckIndex];
if (card) openDetails(card);
return;
}
if (Math.abs(dx) > SWIPE_THRESHOLD) {
flingAndSwipe(dx > 0 ? "like" : "dislike");
} else {
Expand Down Expand Up @@ -593,6 +606,70 @@ function closeTrailer() {
$("trailer-overlay").classList.add("hidden");
}

// ── Details sheet ───────────────────────────────────────────────────────────
async function openDetails(card) {
// Prefill instantly from the card we already have, then enrich from the API.
$("details-poster").style.backgroundImage = card.poster_path
? `url(https://image.tmdb.org/t/p/w780${card.poster_path})`
: "none";
const sheet = document.querySelector(".details-sheet");
if (sheet) sheet.scrollTop = 0; // always open scrolled to the top
$("details-title").textContent = card.title || "";
$("details-tagline").classList.add("hidden");
$("details-meta").textContent = card.release_year ? String(card.release_year) : "";
$("details-overview").textContent = card.overview || "Loading…";
$("details-crew").classList.add("hidden");
$("details-cast").innerHTML = "";
$("details-trailer").onclick = () => openTrailer(card.tmdb_id);
renderRatings(card.tmdb_id, "details-rating");
$("details-overlay").classList.remove("hidden");

try {
const d = await api(`/api/rooms/${state.room.id}/details/${card.tmdb_id}`);
$("details-title").textContent = d.title || card.title || "";
if (d.tagline) {
$("details-tagline").textContent = d.tagline;
$("details-tagline").classList.remove("hidden");
}
const meta = [];
if (d.release_year) meta.push(d.release_year);
if (d.runtime) meta.push(d.media_type === "tv" ? `~${d.runtime} min/ep` : `${d.runtime} min`);
if (d.seasons) meta.push(d.seasons + (d.seasons === 1 ? " season" : " seasons"));
if (d.genres && d.genres.length) meta.push(d.genres.slice(0, 3).join(", "));
$("details-meta").textContent = meta.join(" · ");
$("details-overview").textContent = d.overview || "No description available.";
if (d.directors && d.directors.length) {
const label = (d.media_type === "tv" ? "Creator" : "Director") + (d.directors.length > 1 ? "s" : "");
$("details-crew").textContent = `${label}: ${d.directors.join(", ")}`;
$("details-crew").classList.remove("hidden");
}
const box = $("details-cast");
box.innerHTML = "";
(d.cast || []).forEach((p) => {
const chip = document.createElement("div");
chip.className = "cast-chip";
const photo = document.createElement("div");
photo.className = "cast-photo";
if (p.profile_path) photo.style.backgroundImage = `url(https://image.tmdb.org/t/p/w185${p.profile_path})`;
const name = document.createElement("div");
name.className = "cast-name";
name.textContent = p.name;
const role = document.createElement("div");
role.className = "cast-role";
role.textContent = p.character || "";
chip.appendChild(photo);
chip.appendChild(name);
chip.appendChild(role);
box.appendChild(chip);
});
} catch {
$("details-overview").textContent = card.overview || "Couldn't load details.";
}
}
function closeDetails() {
$("details-overlay").classList.add("hidden");
}

// ── WebSocket (live) ────────────────────────────────────────────────────────
function connectWs() {
if (state.soloMode) return;
Expand Down Expand Up @@ -935,6 +1012,10 @@ function init() {
$("trailer-overlay").onclick = (e) => {
if (e.target === $("trailer-overlay")) closeTrailer();
};
$("details-close").onclick = closeDetails;
$("details-overlay").onclick = (e) => {
if (e.target === $("details-overlay")) closeDetails();
};

const pendingCode = (new URLSearchParams(location.search).get("code") || "").trim().toUpperCase();
if (state.userId && state.username) {
Expand Down
23 changes: 22 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ <h3 id="card-title"></h3>
<p id="card-meta" class="card-sub"></p>
<p id="card-rating" class="rating-row"></p>
<p id="card-overview" class="overview"></p>
<button id="trailer-btn" class="trailer-btn hidden">▶ Trailer</button>
<div class="card-actions">
<button id="details-btn" class="trailer-btn hidden">ⓘ Details</button>
<button id="trailer-btn" class="trailer-btn hidden">▶ Trailer</button>
</div>
</div>
</div>
<p id="deck-empty" class="muted hidden">That's the whole deck! 🎉 Check your matches.</p>
Expand Down Expand Up @@ -174,6 +177,24 @@ <h3>Enter request PIN</h3>
</div>
</div>

<!-- Expanded card / details (cast, director, full overview) -->
<div id="details-overlay" class="overlay hidden">
<button id="details-close" class="overlay-close" aria-label="Close">✕</button>
<div class="details-sheet">
<div id="details-poster" class="details-hero"></div>
<div class="details-body">
<h3 id="details-title"></h3>
<p id="details-tagline" class="details-tagline hidden"></p>
<p id="details-meta" class="card-sub"></p>
<p id="details-rating" class="rating-row"></p>
<p id="details-crew" class="details-crew hidden"></p>
<p id="details-overview" class="details-overview"></p>
<div id="details-cast" class="details-cast"></div>
<button id="details-trailer" class="trailer-btn">▶ Trailer</button>
</div>
</div>
</div>

<!-- Trailer overlay -->
<div id="trailer-overlay" class="overlay hidden">
<button id="trailer-close" class="overlay-close" aria-label="Close">✕</button>
Expand Down
74 changes: 74 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,80 @@ select.input {
border-radius: 12px;
}

/* ── Details sheet ── */
.card-actions {
display: flex;
gap: 0.5rem;
}
.details-sheet {
background: var(--surface);
border-radius: var(--radius);
width: 100%;
max-width: 460px;
max-height: 92vh;
overflow-y: auto;
overflow-x: hidden;
box-shadow: var(--shadow);
-webkit-overflow-scrolling: touch;
}
.details-hero {
width: 100%;
height: 48vh;
background-size: cover;
background-position: center 15%;
background-color: var(--surface-2);
}
.details-body {
padding: 1.1rem;
}
.details-body h3 {
margin: 0 0 0.3rem;
}
.details-tagline {
font-style: italic;
color: var(--muted);
margin: 0 0 0.4rem;
font-size: 0.88rem;
}
.details-crew {
margin: 0.9rem 0 0;
font-size: 0.9rem;
color: var(--muted);
}
.details-overview {
margin: 0.7rem 0 1rem;
line-height: 1.55;
font-size: 0.95rem;
}
.details-cast {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.8rem;
margin-bottom: 1rem;
}
.cast-chip {
text-align: center;
}
.cast-photo {
width: 100%;
aspect-ratio: 1;
border-radius: 50%;
background-size: cover;
background-position: center top;
background-color: var(--surface-2);
margin-bottom: 0.35rem;
}
.cast-name {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.2;
}
.cast-role {
font-size: 0.72rem;
line-height: 1.2;
color: var(--muted);
}

.attribution {
padding: 0.75rem 0;
font-size: 0.7rem;
Expand Down
20 changes: 19 additions & 1 deletion src/routes/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { genId } from "../lib/ids";
import { uniqueJoinCode, userExists } from "../lib/db";
import { mapRoom } from "../lib/mappers";
import { getDeckForUser, getCardFromDeck, resetDeck, buildMatchReason } from "../lib/deck";
import { getWatchProviders, getImdbId, getTrailerKey } from "../services/tmdb";
import { getWatchProviders, getImdbId, getTrailerKey, getTitleDetails } from "../services/tmdb";
import { getOmdbRatings } from "../services/omdb";
import { createRequest } from "../services/overseerr";
import { rateLimit, clientIp } from "../lib/ratelimit";
Expand Down Expand Up @@ -332,6 +332,24 @@ rooms.patch("/:id", async (c) => {
return c.json({ ...room, media_type: mediaType, deck: [] }, 200);
});

// GET /api/rooms/:id/details/:tmdbId — full metadata for the details view
// (cast, director/creator, tagline, runtime, genres, full overview).
rooms.get("/:id/details/:tmdbId", async (c) => {
const id = c.req.param("id");
const tmdbId = Number(c.req.param("tmdbId"));
if (!Number.isInteger(tmdbId) || tmdbId <= 0) return c.json({ error: "tmdb_id_invalid" }, 400);

const row = await c.env.DB.prepare("SELECT media_type FROM rooms WHERE id = ?")
.bind(id)
.first<Record<string, unknown>>();
if (!row) return c.json({ error: "room_not_found" }, 404);
const mediaType = String(row.media_type) === "tv" ? "tv" : "movie";

const details = await getTitleDetails(c.env, mediaType, tmdbId);
if (!details) return c.json({ error: "details_fetch_failed" }, 502);
return c.json(details, 200);
});

// GET /api/rooms/:id/providers/:tmdbId — where a title can be watched (RO).
rooms.get("/:id/providers/:tmdbId", async (c) => {
const id = c.req.param("id");
Expand Down
82 changes: 82 additions & 0 deletions src/services/tmdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,88 @@ export async function getTitleCard(
}
}

interface TmdbCredits {
cast?: { name: string; character?: string; profile_path?: string | null }[];
crew?: { name: string; job?: string; department?: string }[];
}
interface TmdbDetailFull extends TmdbDetail {
tagline?: string;
runtime?: number; // movie
episode_run_time?: number[]; // tv
number_of_seasons?: number; // tv
created_by?: { name: string }[]; // tv
credits?: TmdbCredits;
}

export interface CastMember {
name: string;
character: string | null;
profile_path: string | null;
}
export interface TitleDetails {
tmdb_id: number;
media_type: MediaType;
title: string;
tagline: string | null;
overview: string;
poster_path: string | null;
release_year: number | null;
genres: string[];
runtime: number | null; // minutes (movie) or per-episode (tv)
seasons: number | null; // tv only
vote_average: number | null;
directors: string[]; // Director(s) for movies, Creator(s) for tv
cast: CastMember[];
}

/** Full metadata for the details view: cast, director/creator, runtime, genres. One cached call. */
export async function getTitleDetails(
env: Env,
mediaType: MediaType,
id: number,
): Promise<TitleDetails | null> {
try {
const d = await tmdbFetch<TmdbDetailFull>(env, `/${mediaType}/${id}`, {
language: TMDB_LANG,
append_to_response: "credits",
});
const directors =
mediaType === "movie"
? (d.credits?.crew ?? []).filter((c) => c.job === "Director").map((c) => c.name)
: (d.created_by ?? []).map((c) => c.name);
const runtime =
mediaType === "movie"
? typeof d.runtime === "number"
? d.runtime
: null
: Array.isArray(d.episode_run_time) && d.episode_run_time.length
? d.episode_run_time[0]
: null;
return {
tmdb_id: d.id,
media_type: mediaType,
title: (mediaType === "movie" ? d.title : d.name) ?? "",
tagline: d.tagline?.trim() || null,
overview: d.overview ?? "",
poster_path: d.poster_path ?? null,
release_year: dateToYear(mediaType === "movie" ? d.release_date : d.first_air_date),
genres: (d.genres ?? []).map((g) => g.name),
runtime,
seasons:
mediaType === "tv" && typeof d.number_of_seasons === "number" ? d.number_of_seasons : null,
vote_average: typeof d.vote_average === "number" ? d.vote_average : null,
directors: [...new Set(directors)].slice(0, 3),
cast: (d.credits?.cast ?? []).slice(0, 12).map((c) => ({
name: c.name,
character: c.character?.trim() || null,
profile_path: c.profile_path ?? null,
})),
};
} catch {
return null;
}
}

interface TmdbProviderEntry {
provider_id: number;
provider_name: string;
Expand Down
Loading