テーブル実装

This commit is contained in:
Keisuke Hirata 2026-04-08 22:13:47 +09:00
parent bb64ed0492
commit fa47675790
4 changed files with 302 additions and 0 deletions

View File

@ -3,6 +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";
const LOG = "[yt-playlist-features]"; const LOG = "[yt-playlist-features]";
@ -79,6 +80,8 @@ function handlePlaylistData(event: Event): void {
`Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount}/${metadata.videoCount} videos)`, `Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount}/${metadata.videoCount} videos)`,
); );
mountTable(playlistData);
const message: Message = { type: "PLAYLIST_EXTRACTED", data: playlistData }; const message: Message = { type: "PLAYLIST_EXTRACTED", data: playlistData };
browser.runtime.sendMessage(message).catch((err) => { browser.runtime.sendMessage(message).catch((err) => {
console.error(LOG, "Failed to send playlist data to background:", err); console.error(LOG, "Failed to send playlist data to background:", err);

View File

@ -0,0 +1,47 @@
import type { PlaylistData } from "../../types/playlist";
import { injectStyles } from "./styles";
import { renderPlaylistTable } from "./table-renderer";
const CONTAINER_ID = "ytpf-playlist-table";
export function mountTable(data: PlaylistData): void {
unmountTable();
injectStyles();
const el = renderPlaylistTable(data);
el.id = CONTAINER_ID;
// Primary: before ytd-playlist-video-list-renderer (sibling)
const videoList = document.querySelector("ytd-playlist-video-list-renderer");
if (videoList?.parentElement) {
videoList.parentElement.insertBefore(el, videoList);
return;
}
// Fallback: end of ytd-section-list-renderer > #contents
const sectionContents = document.querySelector(
"ytd-section-list-renderer > #contents",
);
if (sectionContents) {
sectionContents.appendChild(el);
return;
}
// Last resort: #primary
const primary = document.querySelector(
"ytd-two-column-browse-results-renderer > #primary",
);
if (primary) {
primary.appendChild(el);
return;
}
console.warn("[yt-playlist-features] Could not find anchor to mount table");
}
export function unmountTable(): void {
document.getElementById(CONTAINER_ID)?.remove();
}
// Auto-cleanup on SPA navigation
document.addEventListener("yt-navigate-start", unmountTable);

123
src/content/ui/styles.ts Normal file
View File

@ -0,0 +1,123 @@
const STYLE_ID = "ytpf-styles";
const CSS = `
.ytpf-wrapper {
margin: 16px 0;
border: 1px solid var(--yt-spec-10-percent-layer);
border-radius: 12px;
overflow: hidden;
font-family: "Roboto", "Arial", sans-serif;
font-size: 13px;
color: var(--yt-spec-text-primary);
background: var(--yt-spec-base-background);
}
.ytpf-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--yt-spec-badge-chip-background);
border-bottom: 1px solid var(--yt-spec-10-percent-layer);
cursor: pointer;
user-select: none;
}
.ytpf-header-title {
font-size: 14px;
font-weight: 500;
}
.ytpf-header-meta {
font-size: 12px;
color: var(--yt-spec-text-secondary);
}
.ytpf-toggle {
font-size: 12px;
color: var(--yt-spec-text-secondary);
transition: transform 0.2s;
}
.ytpf-toggle--collapsed {
transform: rotate(-90deg);
}
.ytpf-body {
overflow-x: auto;
}
.ytpf-body--hidden {
display: none;
}
.ytpf-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.ytpf-th {
text-align: left;
padding: 8px 12px;
font-weight: 500;
font-size: 12px;
color: var(--yt-spec-text-secondary);
border-bottom: 1px solid var(--yt-spec-10-percent-layer);
white-space: nowrap;
}
.ytpf-td {
padding: 6px 12px;
border-bottom: 1px solid var(--yt-spec-10-percent-layer);
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ytpf-tr:hover {
background: var(--yt-spec-badge-chip-background);
}
.ytpf-col-index { width: 48px; text-align: right; }
.ytpf-col-title { width: auto; }
.ytpf-col-channel { width: 180px; }
.ytpf-col-duration { width: 80px; font-family: "Roboto Mono", monospace; }
.ytpf-col-status { width: 60px; text-align: center; }
.ytpf-tr--unplayable {
opacity: 0.45;
}
.ytpf-link {
color: var(--yt-spec-text-primary);
text-decoration: none;
}
.ytpf-link:hover {
text-decoration: underline;
}
.ytpf-live {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: #c00;
color: #fff;
}
`;
export function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
export function removeStyles(): void {
document.getElementById(STYLE_ID)?.remove();
}

View File

@ -0,0 +1,129 @@
import type { PlaylistData } from "../../types/playlist";
export function renderPlaylistTable(data: PlaylistData): HTMLElement {
const wrapper = document.createElement("div");
wrapper.className = "ytpf-wrapper";
// Header bar
const header = document.createElement("div");
header.className = "ytpf-header";
const titleSpan = document.createElement("span");
titleSpan.className = "ytpf-header-title";
titleSpan.textContent = `${data.metadata.title}${data.extractedCount} videos`;
const metaSpan = document.createElement("span");
metaSpan.className = "ytpf-header-meta";
if (data.metadata.totalDurationText) {
metaSpan.textContent = data.metadata.totalDurationText;
}
const toggle = document.createElement("span");
toggle.className = "ytpf-toggle";
toggle.textContent = "\u25BC";
header.append(titleSpan, metaSpan, toggle);
wrapper.appendChild(header);
// Body (table container)
const body = document.createElement("div");
body.className = "ytpf-body";
const table = document.createElement("table");
table.className = "ytpf-table";
// Thead
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
const columns = [
{ label: "#", cls: "ytpf-col-index" },
{ label: "Title", cls: "ytpf-col-title" },
{ label: "Channel", cls: "ytpf-col-channel" },
{ label: "Duration", cls: "ytpf-col-duration" },
{ label: "Status", cls: "ytpf-col-status" },
];
for (const col of columns) {
const th = document.createElement("th");
th.className = `ytpf-th ${col.cls}`;
th.textContent = col.label;
headRow.appendChild(th);
}
thead.appendChild(headRow);
table.appendChild(thead);
// Tbody
const tbody = document.createElement("tbody");
const playlistId = data.metadata.playlistId;
for (const video of data.videos) {
const tr = document.createElement("tr");
tr.className = "ytpf-tr";
if (!video.isPlayable) {
tr.classList.add("ytpf-tr--unplayable");
}
// Index
const tdIndex = document.createElement("td");
tdIndex.className = "ytpf-td ytpf-col-index";
tdIndex.textContent = String(video.index + 1);
tr.appendChild(tdIndex);
// Title
const tdTitle = document.createElement("td");
tdTitle.className = "ytpf-td ytpf-col-title";
const titleLink = document.createElement("a");
titleLink.className = "ytpf-link";
titleLink.href = `/watch?v=${video.videoId}&list=${playlistId}`;
titleLink.textContent = video.title;
titleLink.title = video.title;
tdTitle.appendChild(titleLink);
tr.appendChild(tdTitle);
// Channel
const tdChannel = document.createElement("td");
tdChannel.className = "ytpf-td ytpf-col-channel";
if (video.channel.url) {
const chLink = document.createElement("a");
chLink.className = "ytpf-link";
chLink.href = video.channel.url;
chLink.textContent = video.channel.name;
tdChannel.appendChild(chLink);
} else {
tdChannel.textContent = video.channel.name;
}
tr.appendChild(tdChannel);
// Duration
const tdDuration = document.createElement("td");
tdDuration.className = "ytpf-td ytpf-col-duration";
if (video.isLive) {
const badge = document.createElement("span");
badge.className = "ytpf-live";
badge.textContent = "LIVE";
tdDuration.appendChild(badge);
} else {
tdDuration.textContent = video.durationText ?? "--";
}
tr.appendChild(tdDuration);
// Status
const tdStatus = document.createElement("td");
tdStatus.className = "ytpf-td ytpf-col-status";
tdStatus.textContent = video.isPlayable ? "\u2713" : "\u2717";
tr.appendChild(tdStatus);
tbody.appendChild(tr);
}
table.appendChild(tbody);
body.appendChild(table);
wrapper.appendChild(body);
// Toggle collapse
header.addEventListener("click", () => {
body.classList.toggle("ytpf-body--hidden");
toggle.classList.toggle("ytpf-toggle--collapsed");
});
return wrapper;
}