playlists(#37): playlist view — renders a playlist's ordered videos

New route #view=playlist&pubkey=<hex>&id=<playlist id>:
- views/playlist.html: title + meta + the shared #videoList grid.
- js/playlistView.js: initPlaylistView() reads pubkey+id from the hash, fetches
  the playlist (facade), resolves its ordered `a` refs against the app's active
  videos (buildVideoAddressPointer keys, same 30078 contract), points the back
  link at the creator's channel, and renders via app.renderVideoList with
  preserveOrder:true so the playlist's order is kept (not re-sorted by date —
  #21). Unresolved refs are skipped; the meta line shows "N of M available".
  Gated by FEATURE_PLAYLISTS.
- FEED_TYPES.PLAYLIST + viewManager registration (getApp: getApplication).

The channel-section cards now resolve to a working view. node --check + lint +
build clean. End-to-end browser verification comes with the create/edit slice
(needs a way to actually make a playlist).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-06 13:16:28 -04:00
1 parent 85903965a4
commit 291d55d966
4 files changed
+163

No files matched your search

+1
View File
@@ -605,4 +605,5 @@ export const FEED_TYPES = Object.freeze({
CHANNEL: "channel-profile",
DOCS: "docs",
SEARCH: "search",
PLAYLIST: "playlist",
});
+130
View File
@@ -0,0 +1,130 @@
// js/playlistView.js
//
// The playlist view (#37): #view=playlist&pubkey=<hex>&id=<playlist id>. Fetches
// the playlist, resolves its ordered `a` refs to the app's active video objects,
// and renders them into the shared #videoList grid via app.renderVideoList
// (preserveOrder so the playlist's order is kept, not re-sorted by date — #21).
// Gated by FEATURE_PLAYLISTS.
import { nostrClient } from "./nostrClientFacade.js";
import { fetchPlaylist } from "./playlists/playlistFacade.js";
import { buildVideoAddressPointer } from "./utils/videoPointer.js";
import { safeEncodeNpub } from "./utils/nostrHelpers.js";
import { FEATURE_PLAYLISTS } from "./constants.js";
import { devLogger } from "./utils/logger.js";
function readParams() {
const params = new URLSearchParams(
typeof window !== "undefined" && window.location
? window.location.hash.slice(1)
: "",
);
return {
pubkey: (params.get("pubkey") || "").trim().toLowerCase(),
id: (params.get("id") || "").trim(),
};
}
// Resolve the playlist's items to the app's active video objects, in playlist
// order. Unresolved refs (video not in the cache) are skipped — the count line
// reflects how many resolved.
function resolveOrderedVideos(playlist) {
let active = [];
try {
active = nostrClient.getActiveVideos() || [];
} catch (error) {
active = [];
}
const byCoordinate = new Map();
const byEventId = new Map();
for (const video of active) {
const coordinate = buildVideoAddressPointer(video);
if (coordinate) {
byCoordinate.set(coordinate, video);
}
if (video && typeof video.id === "string" && video.id) {
byEventId.set(video.id, video);
}
}
const ordered = [];
for (const item of playlist.items) {
const video =
item.type === "a"
? byCoordinate.get(item.value)
: byEventId.get(item.value);
if (video) {
ordered.push(video);
}
}
return ordered;
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) {
el.textContent = text;
}
}
export async function initPlaylistView({ getApp } = {}) {
const app = typeof getApp === "function" ? getApp() : null;
const { pubkey, id } = readParams();
if (!FEATURE_PLAYLISTS) {
setText("playlistTitle", "Playlists are not enabled.");
return;
}
if (!pubkey || !id) {
setText("playlistTitle", "Playlist not found.");
return;
}
// Point the back link at the creator's channel.
const back = document.getElementById("playlistBackLink");
const npub = safeEncodeNpub(pubkey);
if (back && npub) {
back.setAttribute("href", `#view=channel-profile&npub=${npub}`);
}
let playlist = null;
try {
playlist = await fetchPlaylist(pubkey, id);
} catch (error) {
devLogger.warn("[playlistView] Failed to fetch playlist:", error);
}
if (!playlist) {
setText("playlistTitle", "Playlist not found.");
setText("playlistMeta", "");
return;
}
setText("playlistTitle", playlist.title);
const videos = resolveOrderedVideos(playlist);
const total = playlist.items.length;
setText(
"playlistMeta",
videos.length === total
? `${total} ${total === 1 ? "video" : "videos"}`
: `${videos.length} of ${total} videos available`,
);
if (app && typeof app.mountVideoListView === "function") {
app.mountVideoListView({ includeTags: false });
}
if (app && typeof app.renderVideoList === "function") {
try {
await app.renderVideoList({
videos,
metadata: { reason: "playlist", preserveOrder: true },
});
} catch (error) {
devLogger.warn("[playlistView] Failed to render playlist videos:", error);
}
}
}
export default initPlaylistView;
+5
View File
@@ -5,6 +5,7 @@ import { initKidsView } from "./kidsView.js";
import { initExploreView } from "./exploreView.js";
import { initTrendingView } from "./trendingView.js";
import { initMostZappedView } from "./mostZappedView.js";
import { initPlaylistView } from "./playlistView.js";
import { subscriptions } from "./subscriptions.js";
import { getApplication } from "./applicationContext.js";
import { applyDesignSystemAttributes } from "./designSystem.js";
@@ -232,6 +233,10 @@ export const viewInitRegistry = {
// Call the initialization function from channelProfile.js
initChannelProfileView();
},
[FEED_TYPES.PLAYLIST]: () => {
// #37: render a creator playlist's ordered videos.
initPlaylistView({ getApp: getApplication });
},
[FEED_TYPES.SEARCH]: async () => {
try {
const module = await import("./searchView.js");
+27
View File
@@ -0,0 +1,27 @@
<!-- views/playlist.html — creator playlist (#37). Renders the playlist's ordered
videos into the shared #videoList grid via app.renderVideoList. -->
<section data-ds="new">
<div class="mb-4">
<a
href="#view=most-recent-videos"
id="playlistBackLink"
class="text-sm text-info-strong hover:underline"
>← Back to channel</a
>
</div>
<div class="flex items-center gap-2 mb-2">
<h2 id="playlistTitle" class="text-xl font-semibold text-text">Playlist</h2>
</div>
<p id="playlistMeta" class="text-sm text-muted mb-4"></p>
<div id="videoList" class="bv-grid-video" data-testid="video-list">
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
<div class="sidebar-loading-indicator">
<span
class="status-spinner status-spinner--inline"
aria-hidden="true"
></span>
<span class="sidebar-loading-text">Loading playlist…</span>
</div>
</div>
</div>
</section>