From b23de0741a982bc8d8b64870ab446a7bf0223377 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Apr 2026 03:00:56 +0900 Subject: [PATCH] =?UTF-8?q?=E9=A0=86=E6=AC=A1=E8=A1=A8=E7=A4=BA=E3=81=AE?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/extractor.ts | 2 + src/content/index.ts | 53 ++++++++++++++++++++- src/content/ui/i18n.ts | 5 +- src/content/ui/lifecycle.ts | 18 +++++-- src/content/ui/table-renderer.ts | 61 ++++++++++++++++++++++-- src/injected/page-script.ts | 82 ++++++++++++++++++++------------ 6 files changed, 180 insertions(+), 41 deletions(-) diff --git a/src/content/extractor.ts b/src/content/extractor.ts index 74bfe33..d1421f3 100644 --- a/src/content/extractor.ts +++ b/src/content/extractor.ts @@ -175,6 +175,8 @@ function parseVideo(renderer: any): PlaylistVideo | null { (b: any) => b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", ) ?? false, + addedBy: null, + voteCount: null, }; } diff --git a/src/content/index.ts b/src/content/index.ts index f5913bd..beb334a 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -3,7 +3,7 @@ import { onPlaylistPageReady, getPlaylistId } from "./navigation"; import { parseInitialData, buildPlaylistData } from "./extractor"; import type { PlaylistVideo } from "../types/playlist"; import type { Message } from "../shared/messages"; -import { mountTable } from "./ui/lifecycle"; +import { mountTable, appendToTable, setTableComplete } from "./ui/lifecycle"; const LOG = "[yt-playlist-features]"; @@ -31,6 +31,9 @@ function handlePlaylistData(event: Event): void { return; } + // Skip append messages — handled separately + if (payload.type === "append") return; + if (payload.error) { console.warn(LOG, "Page script error:", payload.error); return; @@ -75,7 +78,7 @@ function handlePlaylistData(event: Event): void { } satisfies PlaylistVideo; }); - const playlistData = buildPlaylistData(metadata, videos, true); + const playlistData = buildPlaylistData(metadata, videos, payload.isComplete !== false); console.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 document.addEventListener("__yt_playlist_ext_data", handlePlaylistData); +document.addEventListener("__yt_playlist_ext_data", handlePlaylistAppend); // Detect playlist page navigation and trigger extraction onPlaylistPageReady(() => { diff --git a/src/content/ui/i18n.ts b/src/content/ui/i18n.ts index 1d22ac4..d6af448 100644 --- a/src/content/ui/i18n.ts +++ b/src/content/ui/i18n.ts @@ -9,7 +9,8 @@ type MessageKey = | "filterChannel" | "filterAddedBy" | "badgeLive" - | "headerVideos"; + | "headerVideos" + | "headerLoading"; const messages: Record> = { ja: { @@ -24,6 +25,7 @@ const messages: Record> = { filterAddedBy: "追加者...", badgeLive: "ライブ", headerVideos: "本の動画", + headerLoading: "読み込み中…", }, en: { colIndex: "#", @@ -37,6 +39,7 @@ const messages: Record> = { filterAddedBy: "Added by...", badgeLive: "LIVE", headerVideos: "videos", + headerLoading: "loading…", }, }; diff --git a/src/content/ui/lifecycle.ts b/src/content/ui/lifecycle.ts index 8bb249d..2ed83e4 100644 --- a/src/content/ui/lifecycle.ts +++ b/src/content/ui/lifecycle.ts @@ -1,14 +1,17 @@ -import type { PlaylistData } from "../../types/playlist"; +import type { PlaylistData, PlaylistVideo } from "../../types/playlist"; import { injectStyles } from "./styles"; -import { renderPlaylistTable } from "./table-renderer"; +import { renderPlaylistTable, type PlaylistTableHandle } from "./table-renderer"; const CONTAINER_ID = "ytpf-playlist-table"; +let currentHandle: PlaylistTableHandle | null = null; + export function mountTable(data: PlaylistData): void { unmountTable(); injectStyles(); - const el = renderPlaylistTable(data); + currentHandle = renderPlaylistTable(data); + const el = currentHandle.element; el.id = CONTAINER_ID; // 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"); } +export function appendToTable(newVideos: PlaylistVideo[]): void { + currentHandle?.appendVideos(newVideos); +} + +export function setTableComplete(extractedCount: number): void { + currentHandle?.setComplete(extractedCount); +} + export function unmountTable(): void { + currentHandle = null; document.getElementById(CONTAINER_ID)?.remove(); } diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts index a0a484c..35e56dc 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -25,13 +25,15 @@ function getAllColumns(): Column[] { interface TagInput { container: HTMLElement; getTags(): string[]; + addCandidates(names: string[]): void; } function createTagInput( placeholder: string, - candidates: string[], + initialCandidates: string[], onChange: () => void, ): TagInput { + const candidates = [...initialCandidates]; const tags: string[] = []; const container = document.createElement("div"); @@ -185,7 +187,16 @@ function createTagInput( container.addEventListener("click", () => input.focus()); 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) { @@ -278,7 +289,13 @@ function buildRow( 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( (v) => v.addedBy != null || v.voteCount != null, ); @@ -291,9 +308,17 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { const header = document.createElement("div"); header.className = "ytpf-header"; + let extractedCount = data.extractedCount; + let complete = data.isComplete; + const titleSpan = document.createElement("span"); 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"); metaSpan.className = "ytpf-header-meta"; @@ -445,5 +470,31 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { 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 }; } diff --git a/src/injected/page-script.ts b/src/injected/page-script.ts index 36eb954..49b8a37 100644 --- a/src/injected/page-script.ts +++ b/src/injected/page-script.ts @@ -96,13 +96,13 @@ } } - const allRenderers: any[] = []; + const firstPageRenderers: any[] = []; let continuation: string | null = null; let continuationClickTracking: string | null = null; for (const item of videoList) { if (item.playlistVideoRenderer) { - allRenderers.push(item.playlistVideoRenderer); + firstPageRenderers.push(item.playlistVideoRenderer); } if (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; while (continuation && page < 50) { try { @@ -134,12 +167,13 @@ continuation = null; continuationClickTracking = null; + const pageRenderers: any[] = []; for (const action of data.onResponseReceivedActions ?? []) { const items = action?.appendContinuationItemsAction?.continuationItems; if (!items) continue; for (const item of items) { if (item.playlistVideoRenderer) { - allRenderers.push(item.playlistVideoRenderer); + pageRenderers.push(item.playlistVideoRenderer); } if (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++; } catch (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; } } - - // 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 ---