From 291d55d9668de31f4fc694b7d1abdc711f8c3093 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:16:28 -0400 Subject: [PATCH] =?UTF-8?q?playlists(#37):=20playlist=20view=20=E2=80=94?= =?UTF-8?q?=20renders=20a=20playlist's=20ordered=20videos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New route #view=playlist&pubkey=&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 --- js/constants.js | 1 + js/playlistView.js | 130 ++++++++++++++++++++++++++++++++++++++++++++ js/viewManager.js | 5 ++ views/playlist.html | 27 +++++++++ 4 files changed, 163 insertions(+) create mode 100644 js/playlistView.js create mode 100644 views/playlist.html diff --git a/js/constants.js b/js/constants.js index e65dbb65..1337bf98 100644 --- a/js/constants.js +++ b/js/constants.js @@ -605,4 +605,5 @@ export const FEED_TYPES = Object.freeze({ CHANNEL: "channel-profile", DOCS: "docs", SEARCH: "search", + PLAYLIST: "playlist", }); diff --git a/js/playlistView.js b/js/playlistView.js new file mode 100644 index 00000000..8cfe5188 --- /dev/null +++ b/js/playlistView.js @@ -0,0 +1,130 @@ +// js/playlistView.js +// +// The playlist view (#37): #view=playlist&pubkey=&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; diff --git a/js/viewManager.js b/js/viewManager.js index 5c706cfe..760626c1 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -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"); diff --git a/views/playlist.html b/views/playlist.html new file mode 100644 index 00000000..3dc17b1a --- /dev/null +++ b/views/playlist.html @@ -0,0 +1,27 @@ + +
+ +
+

Playlist

+
+

+
+ +
+