// ==UserScript== // @name Letterboxd Quick Preview // @namespace https://github.com/Dashetty/LetterboxdQuickPreview // @version 4.5.4 // @description Press Shift+Z while hovering a film poster to see an editorial, cinematic preview card with rating, synopsis, and details. // @author Dashetty // @match https://letterboxd.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect letterboxd.com // @updateURL https://raw.githubusercontent.com/Dashetty/LetterboxdQuickPreview/master/letterboxd-preview.user.js // @downloadURL https://raw.githubusercontent.com/Dashetty/LetterboxdQuickPreview/master/letterboxd-preview.user.js // @supportURL https://github.com/Dashetty/LetterboxdQuickPreview/issues // @homepageURL https://github.com/Dashetty/LetterboxdQuickPreview // ==/UserScript== (function () { "use strict"; const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours const CACHE_KEY = "lbxd_preview_cache_v4_5_4"; const PREVIEW_KEY = "z"; const FETCH_TIMEOUT = 8000; const CARD_AUTO_CLOSE_MS = 30000; const MAX_CACHE_ENTRIES = 500; let hoveredFilm = null; let activeCard = null; let cache = new Map(); let autoCloseTimer = null; let currentRequest = null; let fetchId = 0; let colorCache = new Map(); function loadCache() { try { const raw = localStorage.getItem(CACHE_KEY); if (raw) { const parsed = JSON.parse(raw); const now = Date.now(); for (const [key, entry] of Object.entries(parsed)) { if (now - entry.timestamp < CACHE_TTL_MS) { cache.set(key, { data: entry.data, timestamp: entry.timestamp }); } } } } catch (e) { console.warn("[LBXD Preview] Cache load failed:", e); } } function saveCache() { try { const obj = {}; for (const [key, entry] of cache) { obj[key] = { data: entry.data, timestamp: entry.timestamp }; } const keys = Object.keys(obj); if (keys.length > MAX_CACHE_ENTRIES) { const sorted = keys .map((k) => ({ key: k, ts: obj[k].timestamp })) .sort((a, b) => a.ts - b.ts); const toRemove = sorted.length - MAX_CACHE_ENTRIES; for (let i = 0; i < toRemove; i++) { cache.delete(sorted[i].key); delete obj[sorted[i].key]; } } localStorage.setItem(CACHE_KEY, JSON.stringify(obj)); } catch (e) { console.warn("[LBXD Preview] Cache save failed:", e); } } function cleanupStuckCards() { document.querySelectorAll(".lbxd-card").forEach((c) => c.remove()); } GM_addStyle(` :root { --canvas: #0c0c0e; --surface: #1b1613; --surface-2: #221b17; --raised: #2a221c; --inner: #2e2621; --skeleton: #3a3129; --amber: #e8a84a; --amber-2: #f0c876; --amber-dim: rgba(232, 168, 74, 0.14); --title: #f7f2ea; --body: #cdc6ba; --muted: #948d80; --faint: #5a5248; --edge: rgba(255, 245, 230, 0.08); --edge-warm: rgba(232, 168, 74, 0.35); --serif: Georgia, "Times New Roman", Times, serif; --sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", sans-serif; --sp-xs: 4px; --sp-sm: 8px; --sp-md: 12px; --sp-lg: 16px; --sp-xl: 20px; --sp-2xl: 28px; --rd: 14px; --rd-sm: 7px; } .lbxd-card { position: fixed; z-index: 9999; width: 400px; max-width: 94vw; background: var(--card-body, var(--surface)); border: 1px solid var(--edge); border-radius: var(--rd); box-shadow: 0 30px 70px rgba(0, 0, 0, 0.55), 0 8px 24px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.4); overflow: hidden; animation: card-in 0.25s cubic-bezier(0.16, 1, 0.3, 1); touch-action: manipulation; -webkit-tap-highlight-color: rgba(232, 168, 74, 0.2); overscroll-behavior: contain; } @keyframes card-in { from { opacity: 0; transform: translateY(12px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } } @keyframes card-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.97); } } .lbxd-card.closing { animation: card-out 0.12s ease forwards; } .lbxd-hero { position: relative; height: 168px; background: linear-gradient(135deg, var(--surface-2), var(--surface)); } .lbxd-hero-bg { position: absolute; inset: -8px; z-index: 1; background-image: var(--poster-img, none); background-size: cover; background-position: center 20%; filter: blur(4px) brightness(0.85) saturate(110%); box-shadow: inset 0 0 0 1px rgba(0,0,0,0.1); } .lbxd-hero-overlay { position: absolute; inset: 0; z-index: 2; } .lbxd-hero-overlay .layer-atmosphere { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(12,10,9,0.05) 0%, rgba(12,10,9,0.15) 60px, rgba(12,10,9,0.35) 120px, rgba(12,10,9,0.55) 168px ); } .lbxd-hero-overlay .layer-readability { position: absolute; inset: 0; background: radial-gradient(ellipse 80% 45% at 50% 88%, rgba(0,0,0,0.55) 0%, transparent 70%); } .lbxd-hero-overlay .layer-bottom { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 0%, transparent 55%, rgba(12,10,9,0.4) 85%, var(--card-body, var(--surface)) 100% ); } .lbxd-hero-extension { position: relative; height: 32px; margin-top: -32px; z-index: 2; background-image: var(--poster-img, none); background-size: cover; background-position: center 85%; filter: blur(6px) brightness(0.75) saturate(90%); -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%); mask-image: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%); pointer-events: none; } .lbxd-frame { position: absolute; left: var(--sp-lg); right: var(--sp-lg); bottom: var(--sp-lg); z-index: 3; padding: var(--sp-md) var(--sp-lg); border: 1px solid rgba(232, 168, 74, 0.5); border-radius: var(--rd-sm); } .lbxd-frame-row { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } .lbxd-meta { min-width: 0; flex: 1; padding-right: 8px; } .lbxd-title { font-family: var(--serif); font-size: 19px; font-weight: 700; line-height: 1.15; letter-spacing: -0.02em; color: var(--title); margin: 0; font-style: italic; text-shadow: 0 1px 10px rgba(0,0,0,0.5); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lbxd-title { text-decoration: none; } .lbxd-title:hover { text-decoration: underline; text-decoration-color: var(--amber); text-underline-offset: 2px; } .lbxd-title-underline { width: 32px; height: 2px; background: linear-gradient(90deg, var(--amber), transparent); margin-top: var(--sp-xs); border-radius: 1px; } .lbxd-meta-line { display: flex; align-items: center; gap: var(--sp-sm); margin-top: var(--sp-xs); font-family: var(--sans); } .lbxd-year { font-size: 12px; font-weight: 500; line-height: 16px; color: var(--muted); letter-spacing: 0.02em; } .lbxd-runtime { font-size: 12px; font-weight: 400; line-height: 16px; color: var(--muted); } .lbxd-runtime::before { content: '\u00B7'; margin-right: var(--sp-sm); color: var(--faint); } /* ─── Compact text-only rating ──────────────────────── */ .lbxd-rating-compact { display: inline-flex; align-items: baseline; gap: 6px; flex-shrink: 0; margin-left: auto; padding-left: 4px; font-family: var(--sans); font-variant-numeric: tabular-nums; white-space: nowrap; } .lbxd-rating-score { font-size: 15px; font-weight: 700; line-height: 1; color: var(--amber-2); } .lbxd-rating-slash { font-size: 12px; font-weight: 400; color: var(--faint); line-height: 1; } .lbxd-rating-max { font-size: 12px; font-weight: 600; color: var(--muted); line-height: 1; } .lbxd-rating-count { font-size: 10px; font-weight: 500; color: var(--muted); line-height: 1; } .lbxd-rating-count::before { content: '\u00B7'; margin-right: 4px; color: var(--faint); } .lbxd-zone { height: 1px; background: linear-gradient(90deg, transparent 0%, var(--edge) 20%, var(--edge) 80%, transparent 100%); margin: 0; position: relative; z-index: 6; } .lbxd-details { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-sm); padding: var(--sp-lg) var(--sp-xl) var(--sp-sm); position: relative; z-index: 6; font-family: var(--sans); } .lbxd-director { font-size: 14px; font-weight: 400; line-height: 20px; color: var(--body); } .lbxd-director-label { color: var(--amber); font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; margin-right: var(--sp-sm); font-family: var(--sans); } .lbxd-genres { display: flex; flex-wrap: wrap; gap: var(--sp-xs); width: 100%; margin-top: 2px; } .lbxd-genre { font-family: var(--sans); font-size: 10px; font-weight: 600; line-height: 12px; letter-spacing: 0.06em; text-transform: uppercase; padding: 4px 10px; border-radius: var(--rd-sm); border: 1px solid var(--edge-warm); background: var(--amber-dim); color: var(--amber-2); transition: background 0.15s ease, border-color 0.15s ease; cursor: default; } .lbxd-genre:hover { background: rgba(232, 168, 74, 0.2); border-color: var(--amber); } .lbxd-synopsis { padding: var(--sp-md) var(--sp-xl) var(--sp-2xl); font-family: var(--sans); font-size: 14px; font-weight: 400; line-height: 22px; color: var(--body); max-height: 140px; overflow-y: auto; position: relative; z-index: 6; overscroll-behavior: contain; } .lbxd-synopsis::-webkit-scrollbar { width: 3px; } .lbxd-synopsis::-webkit-scrollbar-track { background: transparent; } .lbxd-synopsis::-webkit-scrollbar-thumb { background: var(--edge); border-radius: 2px; } .lbxd-synopsis-mask { mask-image: linear-gradient(to bottom, transparent 0, #000 8px, #000 calc(100% - 8px), transparent 100%); -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 8px, #000 calc(100% - 8px), transparent 100%); } .lbxd-close { position: absolute; top: var(--sp-md); right: var(--sp-md); width: 28px; height: 28px; background: rgba(255,255,255,0.06); border: 1.5px solid var(--muted); border-radius: 50%; color: var(--muted); font-size: 14px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.15s ease, color 0.15s ease, background 0.15s ease, border-color 0.15s ease; z-index: 20; font-family: var(--sans); } .lbxd-close::after { content: ''; position: absolute; inset: -8px; border-radius: 50%; } .lbxd-close:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; background: var(--inner); border-color: var(--amber); } .lbxd-close:hover { color: var(--title); background: var(--inner); border-color: var(--edge-warm); transform: rotate(90deg); } .lbxd-skeleton-hero { height: 168px; background: linear-gradient(100deg, var(--surface-2) 30%, var(--skeleton) 50%, var(--surface-2) 70%); background-size: 200% 100%; animation: skel-shimmer 1.6s ease-in-out infinite; } .lbxd-skeleton-body { padding: var(--sp-lg) var(--sp-xl); display: flex; flex-direction: column; gap: var(--sp-sm); } .lbxd-skeleton-line { height: 14px; border-radius: 3px; background: linear-gradient(100deg, var(--inner) 30%, var(--skeleton) 50%, var(--inner) 70%); background-size: 200% 100%; border: 1px solid var(--edge); animation: skel-shimmer 1.6s ease-in-out infinite; } .lbxd-skeleton-line:nth-child(1) { width: 60%; } .lbxd-skeleton-line:nth-child(2) { width: 90%; } .lbxd-skeleton-line:nth-child(3) { width: 75%; } @keyframes skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } @media (prefers-reduced-motion: reduce) { .lbxd-card { animation: none; } .lbxd-card.closing { animation: fade-out 0.08s ease forwards; } .lbxd-skeleton-hero, .lbxd-skeleton-line { animation: none; } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } .lbxd-error { padding: var(--sp-2xl); text-align: center; position: relative; z-index: 6; font-family: var(--sans); } .lbxd-error-icon { font-size: 22px; margin-bottom: var(--sp-sm); display: block; color: var(--amber); } .lbxd-error-text { font-size: 14px; font-weight: 400; line-height: 20px; color: var(--muted); } .lbxd-hint { position: absolute; bottom: 6px; left: 50%; transform: translateX(-50%); background: var(--surface); color: var(--muted); font-family: var(--sans); font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; padding: 3px 8px; border-radius: var(--rd-sm); pointer-events: none; opacity: 0; transition: opacity 0.2s ease, transform 0.2s ease; z-index: 9998; white-space: nowrap; border: 1px solid var(--edge); } .lbxd-target:hover .lbxd-hint, .lbxd-target.active .lbxd-hint { opacity: 1; transform: translateX(-50%) translateY(-2px); } .lbxd-target { position: relative; } .lbxd-target.active { outline: 2px solid var(--amber); outline-offset: 3px; border-radius: 2px; } `); function getFilmInfo(el) { const link = el.closest('a[href^="/film/"]') || el.querySelector('a[href^="/film/"]'); if (link) { const m = link.getAttribute("href").match(/\/film\/([^\/]+)/); if (m) { const ta = link.getAttribute("data-original-title") || ""; const tm = ta.match(/^(.+?)\s*\((\d{4})\)/); return { slug: m[1], name: tm ? tm[1].trim() : link .querySelector(".frame-title") ?.textContent?.replace(/\(\d{4}\)/, "") .trim() || m[1], year: tm ? tm[2] : null, link: link.getAttribute("href"), }; } } if (el.tagName === "A" && el.getAttribute("href")?.startsWith("/film/")) { const m = el.getAttribute("href").match(/\/film\/([^\/]+)/); if (m) { const ta = el.getAttribute("data-original-title") || ""; const tm = ta.match(/^(.+?)\s*\((\d{4})\)/); return { slug: m[1], name: tm ? tm[1].trim() : el .querySelector(".frame-title") ?.textContent?.replace(/\(\d{4}\)/, "") .trim() || m[1], year: tm ? tm[2] : null, link: el.getAttribute("href"), }; } } return null; } function filmUrl(slug) { return `https://letterboxd.com/film/${slug}/`; } function findPosters() { const sels = ["div.poster.film-poster", 'a.frame.has-menu[href^="/film/"]']; const found = new Set(); sels.forEach((s) => document.querySelectorAll(s).forEach((e) => found.add(e)), ); return Array.from(found); } function attachHint(el) { if (el.querySelector(".lbxd-hint")) return; el.classList.add("lbxd-target"); const h = document.createElement("div"); h.className = "lbxd-hint"; h.textContent = "Shift+Z"; el.style.position = "relative"; el.appendChild(h); } function onDelegatedMouseEnter(e) { const el = e.target.closest( 'div.poster.film-poster, a.frame.has-menu[href^="/film/"]', ); if (!el) return; const info = getFilmInfo(el); if (!info) return; hoveredFilm = { el: el, slug: info.slug, url: info.link ? `https://letterboxd.com${info.link}` : filmUrl(info.slug), name: info.name, year: info.year, }; el.classList.add("active"); } function onDelegatedMouseLeave(e) { const el = e.target.closest( 'div.poster.film-poster, a.frame.has-menu[href^="/film/"]', ); if (!el) return; el.classList.remove("active"); setTimeout(() => { if ( hoveredFilm && hoveredFilm.el === el && !activeCard?.matches(":hover") ) { hoveredFilm = null; } }, 100); } function parsePage(html) { const d = { title: null, year: null, rating: null, ratingCount: null, synopsis: null, runtime: null, director: null, genres: [], posterUrl: null, }; const jm = html.match( /