diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 9549e477..a9171d22 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -2951,34 +2951,62 @@ background-color: currentColor; } - /* #37: channel playlists section — a responsive row of playlist cards. */ + /* #37: channel playlists — each card is a stacked "pile of clips" cover with a + themed gradient scrim and the title set bold over it. */ .playlist-grid { @apply grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4; - gap: var(--space-md); + gap: var(--space-lg); } .playlist-card { - display: flex; - flex-direction: column; - gap: var(--space-2xs); + position: relative; + display: block; + aspect-ratio: 16 / 9; text-decoration: none; - transition: transform 0.12s ease-out; + /* leave room for the offset stack layers to peek out */ + margin-block-start: var(--space-2xs); + margin-inline-end: var(--space-xs); + transition: transform 0.14s ease-out; + } + + /* Two offset layers behind the cover read as a stack of videos. */ + .playlist-card::before, + .playlist-card::after { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--radius-md); + background-color: var(--color-page-alt); + border: var(--border-width-hairline) solid var(--color-overlay-strong); + box-shadow: var(--shadow-sm); + } + + .playlist-card::before { + transform: translate(var(--size-5px), calc(-1 * var(--size-5px))) + rotate(2.5deg); + } + + .playlist-card::after { + transform: translate(calc(-1 * var(--size-4px)), calc(-1 * var(--size-3px))) + rotate(-2deg); + } + + .playlist-card__thumb { + position: absolute; + inset: 0; + z-index: 1; + border-radius: var(--radius-md); + overflow: hidden; + background-color: var(--color-page-alt); + box-shadow: var(--shadow-md); } .playlist-card:hover { transform: translateY(-2px); } - .playlist-card__thumb { - position: relative; - display: flex; - align-items: flex-end; - justify-content: flex-end; - aspect-ratio: 16 / 9; - overflow: hidden; - border-radius: var(--radius-sm); - background-color: var(--color-page-alt); - border: var(--border-width-hairline) solid var(--color-page-alt); + .playlist-card:hover .playlist-card__thumb { + box-shadow: var(--shadow-lg); } .playlist-card__image { @@ -2989,23 +3017,45 @@ object-fit: cover; } + /* Themed scrim: accent-tinted dark at the base fading to clear, so the bold + title reads over any cover (always dark, so the title is always white). */ + .playlist-card__scrim { + position: absolute; + inset: 0; + background-image: linear-gradient( + to top, + color-mix(in srgb, var(--color-accent-strong) 62%, black) 0%, + color-mix(in srgb, var(--color-accent) 26%, transparent) 45%, + transparent 74% + ); + } + .playlist-card__count { - position: relative; - margin: var(--space-2xs); + position: absolute; + z-index: 2; + inset-block-start: var(--space-2xs); + inset-inline-end: var(--space-2xs); border-radius: var(--radius-sm); padding-block: var(--space-3xs); padding-inline: var(--space-2xs); font-size: var(--font-size-2xs); - font-weight: 500; - color: var(--color-text-strong); - background-color: var(--color-page); + font-weight: 600; + color: var(--color-white); + background-color: var(--color-overlay-strong); } .playlist-card__title { + position: absolute; + z-index: 2; + inset-inline: 0; + inset-block-end: 0; margin: 0; + padding: var(--space-sm); font-size: var(--font-size-sm); - font-weight: 500; - color: var(--color-text); + font-weight: 700; + line-height: 1.2; + color: var(--color-white); + text-shadow: 0 var(--size-2px) var(--size-3px) var(--color-overlay-black); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; diff --git a/css/tokens.css b/css/tokens.css index d874fc9c..239f8286 100644 --- a/css/tokens.css +++ b/css/tokens.css @@ -301,6 +301,8 @@ /* Measurement primitives */ --size-2px: 2px; --size-3px: 3px; + --size-4px: 4px; + --size-5px: 5px; --size-12px: 12px; --size-24px: 24px; --size-30px: 30px; diff --git a/js/channelPlaylists.js b/js/channelPlaylists.js index 2be0dcb6..7219f6a6 100644 --- a/js/channelPlaylists.js +++ b/js/channelPlaylists.js @@ -11,6 +11,8 @@ // playlist, instead of needing a manual page reload. import { fetchCreatorPlaylists } from "./playlists/playlistFacade.js"; +import { nostrClient } from "./nostrClientFacade.js"; +import { buildVideoAddressPointer } from "./utils/videoPointer.js"; import { FEATURE_PLAYLISTS } from "./constants.js"; import { devLogger } from "./utils/logger.js"; @@ -35,7 +37,38 @@ function playlistHref(playlist) { return `#${params.toString()}`; } -function renderPlaylistCard(doc, playlist) { +// coord -> thumbnail URL, from the app's active videos, so a playlist can show +// its first video's thumbnail as the cover. +function buildThumbnailMap() { + const map = new Map(); + let active = []; + try { + active = nostrClient.getActiveVideos() || []; + } catch (error) { + active = []; + } + for (const video of active) { + const coordinate = buildVideoAddressPointer(video); + if (coordinate && typeof video.thumbnail === "string" && video.thumbnail) { + map.set(coordinate, video.thumbnail); + } + } + return map; +} + +function coverFor(playlist, thumbnails) { + for (const item of playlist.items) { + if (item.type === "a") { + const thumb = thumbnails.get(item.value); + if (thumb) { + return thumb; + } + } + } + return playlist.image || ""; +} + +function renderPlaylistCard(doc, playlist, coverUrl) { const card = doc.createElement("a"); card.href = playlistHref(playlist); card.className = "playlist-card"; @@ -46,27 +79,33 @@ function renderPlaylistCard(doc, playlist) { const thumb = doc.createElement("div"); thumb.className = "playlist-card__thumb"; - if (playlist.image) { + + if (coverUrl) { const img = doc.createElement("img"); - img.src = playlist.image; + img.src = coverUrl; img.alt = ""; img.loading = "lazy"; img.referrerPolicy = "no-referrer"; img.className = "playlist-card__image"; thumb.appendChild(img); } + + const scrim = doc.createElement("div"); + scrim.className = "playlist-card__scrim"; + thumb.appendChild(scrim); + const count = doc.createElement("span"); count.className = "playlist-card__count"; const n = playlist.items.length; count.textContent = `${n} ${n === 1 ? "video" : "videos"}`; thumb.appendChild(count); - card.appendChild(thumb); const title = doc.createElement("p"); title.className = "playlist-card__title"; title.textContent = playlist.title; - card.appendChild(title); + thumb.appendChild(title); + card.appendChild(thumb); return card; } @@ -111,9 +150,12 @@ async function refresh() { } clearChildren(grid); + const thumbnails = buildThumbnailMap(); const frag = doc.createDocumentFragment(); for (const playlist of playlists) { - frag.appendChild(renderPlaylistCard(doc, playlist)); + frag.appendChild( + renderPlaylistCard(doc, playlist, coverFor(playlist, thumbnails)), + ); } grid.appendChild(frag); section.classList.remove("hidden"); diff --git a/views/playlist.html b/views/playlist.html index 3dc17b1a..46ba9ce3 100644 --- a/views/playlist.html +++ b/views/playlist.html @@ -5,9 +5,23 @@ ← Back to channel + + Back to channel +

Playlist