順次表示の実装
This commit is contained in:
parent
65e2f28f8e
commit
b23de0741a
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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…",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
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";
|
||||||
|
|
||||||
|
let currentHandle: PlaylistTableHandle | null = null;
|
||||||
|
|
||||||
export function mountTable(data: PlaylistData): void {
|
export function mountTable(data: PlaylistData): void {
|
||||||
unmountTable();
|
unmountTable();
|
||||||
injectStyles();
|
injectStyles();
|
||||||
|
|
||||||
const el = renderPlaylistTable(data);
|
currentHandle = renderPlaylistTable(data);
|
||||||
|
const el = currentHandle.element;
|
||||||
el.id = CONTAINER_ID;
|
el.id = CONTAINER_ID;
|
||||||
|
|
||||||
// Primary: before ytd-playlist-video-list-renderer (sibling)
|
// Primary: before ytd-playlist-video-list-renderer (sibling)
|
||||||
|
|
@ -39,7 +42,16 @@ export function mountTable(data: PlaylistData): void {
|
||||||
console.warn("[yt-playlist-features] Could not find anchor to mount table");
|
console.warn("[yt-playlist-features] Could not find anchor to mount table");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
currentHandle = null;
|
||||||
document.getElementById(CONTAINER_ID)?.remove();
|
document.getElementById(CONTAINER_ID)?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,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);
|
||||||
|
|
@ -111,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 {
|
||||||
|
|
@ -134,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);
|
||||||
|
|
@ -148,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 ---
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user