Compare commits

...

3 Commits

Author SHA1 Message Date
38dbd504d6 初回ロードのマウントポイント待機 2026-04-09 03:01:58 +09:00
b23de0741a 順次表示の実装 2026-04-09 03:00:56 +09:00
65e2f28f8e SPA遷移の修正 2026-04-09 02:46:06 +09:00
6 changed files with 223 additions and 56 deletions

View File

@ -175,6 +175,8 @@ function parseVideo(renderer: any): PlaylistVideo | null {
(b: any) => (b: any) =>
b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW",
) ?? false, ) ?? false,
addedBy: null,
voteCount: null,
}; };
} }

View File

@ -3,7 +3,7 @@ import { onPlaylistPageReady, getPlaylistId } from "./navigation";
import { parseInitialData, buildPlaylistData } from "./extractor"; import { parseInitialData, buildPlaylistData } from "./extractor";
import type { PlaylistVideo } from "../types/playlist"; import type { PlaylistVideo } from "../types/playlist";
import type { Message } from "../shared/messages"; import type { Message } from "../shared/messages";
import { mountTable } from "./ui/lifecycle"; import { mountTable, appendToTable, setTableComplete } from "./ui/lifecycle";
const LOG = "[yt-playlist-features]"; const LOG = "[yt-playlist-features]";
@ -31,6 +31,9 @@ function handlePlaylistData(event: Event): void {
return; return;
} }
// Skip append messages — handled separately
if (payload.type === "append") return;
if (payload.error) { if (payload.error) {
console.warn(LOG, "Page script error:", payload.error); console.warn(LOG, "Page script error:", payload.error);
return; return;
@ -75,7 +78,7 @@ function handlePlaylistData(event: Event): void {
} satisfies PlaylistVideo; } satisfies PlaylistVideo;
}); });
const playlistData = buildPlaylistData(metadata, videos, true); const playlistData = buildPlaylistData(metadata, videos, payload.isComplete !== false);
console.log( console.log(
LOG, LOG,
@ -90,8 +93,54 @@ function handlePlaylistData(event: Event): void {
}); });
} }
function handlePlaylistAppend(event: Event): void {
const detail = (event as CustomEvent).detail;
if (!detail) return;
let payload: any;
try {
payload = JSON.parse(detail);
} catch {
return;
}
if (payload.type === "append") {
const newVideos: PlaylistVideo[] = payload.videos.map((v: any) => {
const bylineRun = v.shortBylineText?.runs?.[0];
const bylineEndpoint = bylineRun?.navigationEndpoint?.browseEndpoint;
return {
videoId: v.videoId,
title: v.title,
index: v.index,
durationSeconds: v.lengthSeconds,
durationText: v.lengthText || null,
thumbnails: v.thumbnails,
channel: {
name: bylineRun?.text ?? "",
channelId: bylineEndpoint?.browseId ?? "",
url: bylineRun?.navigationEndpoint?.commandMetadata
?.webCommandMetadata?.url ?? "",
},
isPlayable: v.isPlayable,
isLive:
v.badges?.some(
(b: any) =>
b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW",
) ?? false,
addedBy: v.addedBy ?? null,
voteCount: v.voteCount ?? null,
} satisfies PlaylistVideo;
});
appendToTable(newVideos);
if (payload.isComplete) {
setTableComplete(payload.totalCount);
}
}
}
// Listen for data from the injected page script // Listen for data from the injected page script
document.addEventListener("__yt_playlist_ext_data", handlePlaylistData); document.addEventListener("__yt_playlist_ext_data", handlePlaylistData);
document.addEventListener("__yt_playlist_ext_data", handlePlaylistAppend);
// Detect playlist page navigation and trigger extraction // Detect playlist page navigation and trigger extraction
onPlaylistPageReady(() => { onPlaylistPageReady(() => {

View File

@ -9,7 +9,8 @@ type MessageKey =
| "filterChannel" | "filterChannel"
| "filterAddedBy" | "filterAddedBy"
| "badgeLive" | "badgeLive"
| "headerVideos"; | "headerVideos"
| "headerLoading";
const messages: Record<string, Record<MessageKey, string>> = { const messages: Record<string, Record<MessageKey, string>> = {
ja: { ja: {
@ -24,6 +25,7 @@ const messages: Record<string, Record<MessageKey, string>> = {
filterAddedBy: "追加者...", filterAddedBy: "追加者...",
badgeLive: "ライブ", badgeLive: "ライブ",
headerVideos: "本の動画", headerVideos: "本の動画",
headerLoading: "読み込み中…",
}, },
en: { en: {
colIndex: "#", colIndex: "#",
@ -37,6 +39,7 @@ const messages: Record<string, Record<MessageKey, string>> = {
filterAddedBy: "Added by...", filterAddedBy: "Added by...",
badgeLive: "LIVE", badgeLive: "LIVE",
headerVideos: "videos", headerVideos: "videos",
headerLoading: "loading…",
}, },
}; };

View File

@ -1,21 +1,16 @@
import type { PlaylistData } from "../../types/playlist"; import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
import { injectStyles } from "./styles"; import { injectStyles } from "./styles";
import { renderPlaylistTable } from "./table-renderer"; import { renderPlaylistTable, type PlaylistTableHandle } from "./table-renderer";
const CONTAINER_ID = "ytpf-playlist-table"; const CONTAINER_ID = "ytpf-playlist-table";
export function mountTable(data: PlaylistData): void { let currentHandle: PlaylistTableHandle | null = null;
unmountTable();
injectStyles();
const el = renderPlaylistTable(data);
el.id = CONTAINER_ID;
function findAnchor(): { parent: Element; before: Element | null } | null {
// Primary: before ytd-playlist-video-list-renderer (sibling) // Primary: before ytd-playlist-video-list-renderer (sibling)
const videoList = document.querySelector("ytd-playlist-video-list-renderer"); const videoList = document.querySelector("ytd-playlist-video-list-renderer");
if (videoList?.parentElement) { if (videoList?.parentElement) {
videoList.parentElement.insertBefore(el, videoList); return { parent: videoList.parentElement, before: videoList };
return;
} }
// Fallback: end of ytd-section-list-renderer > #contents // Fallback: end of ytd-section-list-renderer > #contents
@ -23,8 +18,7 @@ export function mountTable(data: PlaylistData): void {
"ytd-section-list-renderer > #contents", "ytd-section-list-renderer > #contents",
); );
if (sectionContents) { if (sectionContents) {
sectionContents.appendChild(el); return { parent: sectionContents, before: null };
return;
} }
// Last resort: #primary // Last resort: #primary
@ -32,14 +26,58 @@ export function mountTable(data: PlaylistData): void {
"ytd-two-column-browse-results-renderer > #primary", "ytd-two-column-browse-results-renderer > #primary",
); );
if (primary) { if (primary) {
primary.appendChild(el); return { parent: primary, before: null };
}
return null;
}
function insertAt(el: HTMLElement, anchor: { parent: Element; before: Element | null }): void {
anchor.parent.insertBefore(el, anchor.before);
}
let pendingObserver: MutationObserver | null = null;
export function mountTable(data: PlaylistData): void {
unmountTable();
injectStyles();
currentHandle = renderPlaylistTable(data);
const el = currentHandle.element;
el.id = CONTAINER_ID;
const anchor = findAnchor();
if (anchor) {
insertAt(el, anchor);
return; return;
} }
console.warn("[yt-playlist-features] Could not find anchor to mount table"); // DOM not ready yet — wait for the anchor element to appear
pendingObserver = new MutationObserver(() => {
const a = findAnchor();
if (a) {
pendingObserver!.disconnect();
pendingObserver = null;
insertAt(el, a);
}
});
pendingObserver.observe(document.body, { childList: true, subtree: true });
}
export function appendToTable(newVideos: PlaylistVideo[]): void {
currentHandle?.appendVideos(newVideos);
}
export function setTableComplete(extractedCount: number): void {
currentHandle?.setComplete(extractedCount);
} }
export function unmountTable(): void { export function unmountTable(): void {
if (pendingObserver) {
pendingObserver.disconnect();
pendingObserver = null;
}
currentHandle = null;
document.getElementById(CONTAINER_ID)?.remove(); document.getElementById(CONTAINER_ID)?.remove();
} }

View File

@ -25,13 +25,15 @@ function getAllColumns(): Column[] {
interface TagInput { interface TagInput {
container: HTMLElement; container: HTMLElement;
getTags(): string[]; getTags(): string[];
addCandidates(names: string[]): void;
} }
function createTagInput( function createTagInput(
placeholder: string, placeholder: string,
candidates: string[], initialCandidates: string[],
onChange: () => void, onChange: () => void,
): TagInput { ): TagInput {
const candidates = [...initialCandidates];
const tags: string[] = []; const tags: string[] = [];
const container = document.createElement("div"); const container = document.createElement("div");
@ -185,7 +187,16 @@ function createTagInput(
container.addEventListener("click", () => input.focus()); container.addEventListener("click", () => input.focus());
container.append(input, dropdown); container.append(input, dropdown);
return { container, getTags: () => [...tags] }; return {
container,
getTags: () => [...tags],
addCandidates(names: string[]) {
for (const name of names) {
if (!candidates.includes(name)) candidates.push(name);
}
candidates.sort();
},
};
} }
function compareFn(key: SortKey, dir: SortDir) { function compareFn(key: SortKey, dir: SortDir) {
@ -278,7 +289,13 @@ function buildRow(
return tr; return tr;
} }
export function renderPlaylistTable(data: PlaylistData): HTMLElement { export interface PlaylistTableHandle {
element: HTMLElement;
appendVideos(newVideos: PlaylistVideo[]): void;
setComplete(extractedCount: number): void;
}
export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle {
const isCollab = data.videos.some( const isCollab = data.videos.some(
(v) => v.addedBy != null || v.voteCount != null, (v) => v.addedBy != null || v.voteCount != null,
); );
@ -291,9 +308,17 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
const header = document.createElement("div"); const header = document.createElement("div");
header.className = "ytpf-header"; header.className = "ytpf-header";
let extractedCount = data.extractedCount;
let complete = data.isComplete;
const titleSpan = document.createElement("span"); const titleSpan = document.createElement("span");
titleSpan.className = "ytpf-header-title"; titleSpan.className = "ytpf-header-title";
titleSpan.textContent = `${data.metadata.title}${data.extractedCount} ${t("headerVideos")}`;
function updateHeader() {
const status = complete ? "" : ` (${t("headerLoading")})`;
titleSpan.textContent = `${data.metadata.title}${extractedCount} ${t("headerVideos")}${status}`;
}
updateHeader();
const metaSpan = document.createElement("span"); const metaSpan = document.createElement("span");
metaSpan.className = "ytpf-header-meta"; metaSpan.className = "ytpf-header-meta";
@ -445,5 +470,31 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
toggle.classList.toggle("ytpf-toggle--collapsed"); toggle.classList.toggle("ytpf-toggle--collapsed");
}); });
return wrapper; function appendVideos(newVideos: PlaylistVideo[]) {
videos.push(...newVideos);
extractedCount += newVideos.length;
// Update filter candidates
channelTagInput.addCandidates(newVideos.map((v) => v.channel.name));
if (addedByTagInput) {
addedByTagInput.addCandidates(
newVideos.map((v) => v.addedBy).filter((n): n is string => n != null),
);
}
// Re-sort if a sort is active
if (sortKey) {
videos.sort(compareFn(sortKey, sortDir));
}
renderRows();
updateHeader();
}
function setComplete(count: number) {
extractedCount = count;
complete = true;
updateHeader();
}
return { element: wrapper, appendVideos, setComplete };
} }

View File

@ -16,6 +16,7 @@
w.__yt_playlist_ext_injected = true; w.__yt_playlist_ext_injected = true;
let extractingUrl: string | null = null; let extractingUrl: string | null = null;
let isInitialLoad = true;
let collabCache: Map<string, string> = new Map(); let collabCache: Map<string, string> = new Map();
let collabCachePlaylistId: string | null = null; let collabCachePlaylistId: string | null = null;
@ -64,12 +65,13 @@
const { cfg, apiKey, baseContext } = getConfig(); const { cfg, apiKey, baseContext } = getConfig();
const authHeaders = buildAuthHeaders(cfg, baseContext); const authHeaders = buildAuthHeaders(cfg, baseContext);
// Try ytInitialData first (works on initial page load) // Try ytInitialData only on initial page load.
let initialData = w.ytInitialData; // On SPA navigation ytInitialData is stale (still holds the previous page's data),
// so we must always fetch fresh data via the browse API.
let initialData = isInitialLoad ? w.ytInitialData : null;
let videoList = initialData ? findVideoListContents(initialData) : null; let videoList = initialData ? findVideoListContents(initialData) : null;
isInitialLoad = false;
// If ytInitialData doesn't have the video list (SPA navigation),
// fetch the playlist data ourselves via the browse API
if (!videoList) { if (!videoList) {
const playlistId = getPlaylistId(); const playlistId = getPlaylistId();
if (!playlistId) { if (!playlistId) {
@ -94,13 +96,13 @@
} }
} }
const allRenderers: any[] = []; const firstPageRenderers: any[] = [];
let continuation: string | null = null; let continuation: string | null = null;
let continuationClickTracking: string | null = null; let continuationClickTracking: string | null = null;
for (const item of videoList) { for (const item of videoList) {
if (item.playlistVideoRenderer) { if (item.playlistVideoRenderer) {
allRenderers.push(item.playlistVideoRenderer); firstPageRenderers.push(item.playlistVideoRenderer);
} }
if (item.continuationItemRenderer) { if (item.continuationItemRenderer) {
const extracted = extractContinuationInfo(item.continuationItemRenderer); const extracted = extractContinuationInfo(item.continuationItemRenderer);
@ -109,7 +111,40 @@
} }
} }
// Fetch remaining pages // Fetch collaborator names for collaborative playlists
const avatarToName = await fetchCollaboratorMap(initialData, cfg, baseContext, authHeaders);
function mapRenderers(renderers: any[], startIndex: number) {
return renderers.map((d, i) => ({
videoId: d.videoId,
title: textOf(d.title),
index: startIndex + i,
lengthSeconds: d.lengthSeconds ? parseInt(d.lengthSeconds, 10) : null,
lengthText: textOf(d.lengthText),
thumbnails: d.thumbnail?.thumbnails ?? [],
shortBylineText: d.shortBylineText,
isPlayable: d.isPlayable !== false,
badges: d.badges ?? [],
voteCount: typeof d.voteCount === "number" ? d.voteCount : null,
addedBy: resolveAddedBy(d, avatarToName),
}));
}
// Send first page immediately
const hasContinuation = !!continuation;
const firstPageVideos = mapRenderers(firstPageRenderers, 0);
console.log(LOG, `First page: ${firstPageVideos.length} videos (has more: ${hasContinuation})`);
sendResult({
videos: firstPageVideos,
initialData,
innertubeApiKey: apiKey,
innertubeContext: baseContext,
isComplete: !hasContinuation,
});
// Fetch remaining pages lazily and send incremental updates
let totalCount = firstPageVideos.length;
let page = 1; let page = 1;
while (continuation && page < 50) { while (continuation && page < 50) {
try { try {
@ -132,12 +167,13 @@
continuation = null; continuation = null;
continuationClickTracking = null; continuationClickTracking = null;
const pageRenderers: any[] = [];
for (const action of data.onResponseReceivedActions ?? []) { for (const action of data.onResponseReceivedActions ?? []) {
const items = action?.appendContinuationItemsAction?.continuationItems; const items = action?.appendContinuationItemsAction?.continuationItems;
if (!items) continue; if (!items) continue;
for (const item of items) { for (const item of items) {
if (item.playlistVideoRenderer) { if (item.playlistVideoRenderer) {
allRenderers.push(item.playlistVideoRenderer); pageRenderers.push(item.playlistVideoRenderer);
} }
if (item.continuationItemRenderer) { if (item.continuationItemRenderer) {
const extracted = extractContinuationInfo(item.continuationItemRenderer); const extracted = extractContinuationInfo(item.continuationItemRenderer);
@ -146,38 +182,26 @@
} }
} }
} }
const pageVideos = mapRenderers(pageRenderers, totalCount);
totalCount += pageVideos.length;
console.log(LOG, `Continuation page ${page}: +${pageVideos.length} videos (total: ${totalCount})`);
sendResult({
type: "append",
videos: pageVideos,
isComplete: !continuation,
totalCount,
});
page++; page++;
} catch (e) { } catch (e) {
console.error(LOG, `Continuation page ${page} failed:`, e); console.error(LOG, `Continuation page ${page} failed:`, e);
// Send completion even on error so the loading indicator goes away
sendResult({ type: "append", videos: [], isComplete: true, totalCount });
break; break;
} }
} }
// Fetch collaborator names for collaborative playlists
const avatarToName = await fetchCollaboratorMap(initialData, cfg, baseContext, authHeaders);
const videos = allRenderers.map((d, i) => ({
videoId: d.videoId,
title: textOf(d.title),
index: i,
lengthSeconds: d.lengthSeconds ? parseInt(d.lengthSeconds, 10) : null,
lengthText: textOf(d.lengthText),
thumbnails: d.thumbnail?.thumbnails ?? [],
shortBylineText: d.shortBylineText,
isPlayable: d.isPlayable !== false,
badges: d.badges ?? [],
voteCount: typeof d.voteCount === "number" ? d.voteCount : null,
addedBy: resolveAddedBy(d, avatarToName),
}));
console.log(LOG, `Extraction: ${videos.length} videos (${page - 1} continuation pages)`);
sendResult({
videos,
initialData,
innertubeApiKey: apiKey,
innertubeContext: baseContext,
});
} }
// --- helpers --- // --- helpers ---