mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
85903965a4
commit
291d55d966
4 files changed
+163
No files matched your search
@@ -605,4 +605,5 @@ export const FEED_TYPES = Object.freeze({
|
||||
CHANNEL: "channel-profile",
|
||||
DOCS: "docs",
|
||||
SEARCH: "search",
|
||||
PLAYLIST: "playlist",
|
||||
});
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user