From 4c19c6491c5342b1b7ea993ccbf3ef86612b04a7 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Apr 2026 04:57:27 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=AB=E3=83=A9=E3=83=A0=E3=81=AE=E5=8F=AF?= =?UTF-8?q?=E8=A6=96=E5=88=B6=E5=BE=A1=E3=81=A8=E5=B9=85=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/ui/i18n.ts | 8 +- src/content/ui/lifecycle.ts | 7 +- src/content/ui/styles.ts | 145 +++++++++++ src/content/ui/table-renderer.ts | 415 ++++++++++++++++++++++--------- 4 files changed, 447 insertions(+), 128 deletions(-) diff --git a/src/content/ui/i18n.ts b/src/content/ui/i18n.ts index a9a0e91..d9caaf4 100644 --- a/src/content/ui/i18n.ts +++ b/src/content/ui/i18n.ts @@ -17,7 +17,9 @@ type MessageKey = | "headerLoading" | "fetchViews" | "fetchViewsProgress" - | "fetchViewsDone"; + | "fetchViewsDone" + | "colSettings" + | "colSettingsReset"; const messages: Record> = { ja: { @@ -40,6 +42,8 @@ const messages: Record> = { fetchViews: "再生数を取得", fetchViewsProgress: "取得中…", fetchViewsDone: "取得完了", + colSettings: "表示", + colSettingsReset: "リセット", }, en: { colIndex: "#", @@ -61,6 +65,8 @@ const messages: Record> = { fetchViews: "Fetch views", fetchViewsProgress: "Fetching…", fetchViewsDone: "Done", + colSettings: "View", + colSettingsReset: "Reset", }, }; diff --git a/src/content/ui/lifecycle.ts b/src/content/ui/lifecycle.ts index 5d62816..2771df8 100644 --- a/src/content/ui/lifecycle.ts +++ b/src/content/ui/lifecycle.ts @@ -1,7 +1,7 @@ import type { PlaylistData, PlaylistVideo } from "../../types/playlist"; import { injectStyles } from "./styles"; import type { DetailUpdate } from "../../types/playlist"; -import { renderPlaylistTable, type PlaylistTableHandle } from "./table-renderer"; +import { renderPlaylistTable, loadColumnPrefs, type PlaylistTableHandle } from "./table-renderer"; const CONTAINER_ID = "ytpf-playlist-table"; @@ -41,11 +41,12 @@ function insertAt(el: HTMLElement, anchor: { parent: Element; before: Element | let pendingObserver: MutationObserver | null = null; -export function mountTable(data: PlaylistData): void { +export async function mountTable(data: PlaylistData): Promise { unmountTable(); injectStyles(); - currentHandle = renderPlaylistTable(data); + const prefs = await loadColumnPrefs(); + currentHandle = renderPlaylistTable(data, prefs); const el = currentHandle.element; el.id = CONTAINER_ID; diff --git a/src/content/ui/styles.ts b/src/content/ui/styles.ts index e96df06..974faaa 100644 --- a/src/content/ui/styles.ts +++ b/src/content/ui/styles.ts @@ -242,6 +242,7 @@ html[dark] .ytpf-filter-count { } .ytpf-th { + position: relative; text-align: left; padding: 8px 12px; font-weight: 500; @@ -342,6 +343,150 @@ html[dark] .ytpf-fetch-views-btn:hover:not(:disabled) { opacity: 0.6; cursor: default; } + +.ytpf-menu-anchor { + position: relative; +} + +.ytpf-menu-btn { + padding: 4px 8px; + border: none; + background: transparent; + color: var(--yt-spec-text-secondary, #606060); + font-size: 20px; + line-height: 1; + cursor: pointer; + border-radius: 50%; +} + +.ytpf-menu-btn:hover { + background: var(--yt-spec-badge-chip-background, #f2f2f2); +} + +html[dark] .ytpf-menu-btn { + color: #aaa; +} + +html[dark] .ytpf-menu-btn:hover { + background: #3e3e3e; +} + +.ytpf-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: #fff; + border: 1px solid rgba(0,0,0,0.1); + border-radius: 8px; + padding: 4px 0; + min-width: 160px; + z-index: 2001; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +html[dark] .ytpf-menu { + background: #282828; + border-color: rgba(255,255,255,0.1); + box-shadow: 0 4px 12px rgba(0,0,0,0.4); +} + +.ytpf-menu-heading { + padding: 6px 12px 2px; + font-size: 11px; + font-weight: 500; + color: var(--yt-spec-text-secondary, #606060); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +html[dark] .ytpf-menu-heading { + color: #aaa; +} + +.ytpf-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + font-size: 13px; + color: var(--yt-spec-text-primary, #0f0f0f); + cursor: pointer; +} + +html[dark] .ytpf-menu-item { + color: #f1f1f1; +} + +.ytpf-menu-item:hover { + background: var(--yt-spec-badge-chip-background, #f2f2f2); +} + +html[dark] .ytpf-menu-item:hover { + background: #3e3e3e; +} + +.ytpf-menu-item input[type="checkbox"] { + margin: 0; + flex-shrink: 0; +} + +.ytpf-menu-sep { + height: 1px; + margin: 4px 0; + background: var(--yt-spec-10-percent-layer, rgba(0,0,0,0.1)); +} + +html[dark] .ytpf-menu-sep { + background: rgba(255,255,255,0.1); +} + +.ytpf-menu-action { + display: block; + width: 100%; + padding: 5px 12px; + border: none; + background: transparent; + color: var(--yt-spec-text-secondary, #606060); + font-size: 12px; + font-family: "Roboto", "Arial", sans-serif; + cursor: pointer; + text-align: left; +} + +html[dark] .ytpf-menu-action { + color: #aaa; +} + +.ytpf-menu-action:hover { + background: var(--yt-spec-badge-chip-background, #f2f2f2); +} + +html[dark] .ytpf-menu-action:hover { + background: #3e3e3e; +} + +.ytpf-resize-handle { + position: absolute; + top: 0; + right: -2px; + width: 5px; + height: 100%; + cursor: col-resize; + z-index: 1; +} + +.ytpf-resize-handle:hover, +.ytpf-resize-handle--active { + background: var(--yt-spec-call-to-action, #065fd4); + opacity: 0.4; +} + +html[dark] .ytpf-resize-handle:hover, +html[dark] .ytpf-resize-handle--active { + background: #3ea6ff; + opacity: 0.4; +} .ytpf-tr--unplayable { opacity: 0.45; } diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts index 8efbb26..e73c1a7 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -10,24 +10,65 @@ interface Column { label: string; cls: string; key: SortKey; - collab?: boolean; // only shown for collaborative playlists - detail?: boolean; // shown after detail fetch + collab?: boolean; + detail?: boolean; + defaultWidth: number; // default width in % } function getAllColumns(hasDetails: boolean): Column[] { return [ - { label: t("colIndex"), cls: "ytpf-col-index", key: "index" }, - { label: t("colTitle"), cls: "ytpf-col-title", key: "title" }, - { label: t("colChannel"), cls: "ytpf-col-channel", key: "channel" }, - { label: t("colDuration"), cls: "ytpf-col-duration", key: "duration" }, - { label: t("colViews"), cls: "ytpf-col-views", key: "views" }, - { label: t("colPublished"), cls: "ytpf-col-published", key: "published", detail: true }, - { label: t("colCategory"), cls: "ytpf-col-category", key: "category", detail: true }, - { label: t("colAddedBy"), cls: "ytpf-col-addedby", key: "addedBy", collab: true }, - { label: t("colVotes"), cls: "ytpf-col-votes", key: "votes", collab: true }, + { label: t("colIndex"), cls: "ytpf-col-index", key: "index", defaultWidth: 5 }, + { label: t("colTitle"), cls: "ytpf-col-title", key: "title", defaultWidth: 40 }, + { label: t("colChannel"), cls: "ytpf-col-channel", key: "channel", defaultWidth: 20 }, + { label: t("colDuration"), cls: "ytpf-col-duration", key: "duration", defaultWidth: 8 }, + { label: t("colViews"), cls: "ytpf-col-views", key: "views", defaultWidth: 12 }, + { label: t("colPublished"), cls: "ytpf-col-published", key: "published", detail: true, defaultWidth: 10 }, + { label: t("colCategory"), cls: "ytpf-col-category", key: "category", detail: true, defaultWidth: 10 }, + { label: t("colAddedBy"), cls: "ytpf-col-addedby", key: "addedBy", collab: true, defaultWidth: 14 }, + { label: t("colVotes"), cls: "ytpf-col-votes", key: "votes", collab: true, defaultWidth: 6 }, ]; } +// --- Column preferences persistence --- + +interface ColumnPref { + visible: boolean; + width: number; +} + +export type ColumnPrefs = Record; + +export async function loadColumnPrefs(): Promise { + const result = await browser.storage.local.get("ytpf-column-prefs"); + return (result["ytpf-column-prefs"] as ColumnPrefs) ?? {}; +} + +async function saveColumnPrefs(prefs: ColumnPrefs): Promise { + await browser.storage.local.set({ "ytpf-column-prefs": prefs }); +} + +function getColumnVisible(col: Column, prefs: ColumnPrefs): boolean { + return prefs[col.key]?.visible ?? true; +} + +function getColumnWidth(col: Column, prefs: ColumnPrefs): number { + return prefs[col.key]?.width ?? col.defaultWidth; +} + +function getVisibleColumns(available: Column[], prefs: ColumnPrefs): Column[] { + return available.filter((c) => getColumnVisible(c, prefs)); +} + +function applyColumnWidths(thElements: HTMLTableCellElement[], visibleColumns: Column[], prefs: ColumnPrefs): void { + const widths = visibleColumns.map((c) => getColumnWidth(c, prefs)); + const total = widths.reduce((a, b) => a + b, 0); + for (let i = 0; i < thElements.length; i++) { + thElements[i].style.width = `${(widths[i] / total) * 100}%`; + } +} + +// --- Tag input --- + interface TagInput { container: HTMLElement; getTags(): string[]; @@ -243,92 +284,75 @@ function compareFn(key: SortKey, dir: SortDir) { }; } +function buildCell(video: PlaylistVideo, col: Column, playlistId: string): HTMLTableCellElement { + const td = document.createElement("td"); + td.className = `ytpf-td ${col.cls}`; + + switch (col.key) { + case "index": + td.textContent = String(video.index + 1); + break; + case "title": { + const link = document.createElement("a"); + link.className = "ytpf-link"; + link.href = `/watch?v=${video.videoId}&list=${playlistId}`; + link.textContent = video.title; + link.title = video.title; + td.appendChild(link); + break; + } + case "channel": + if (video.channel.url) { + const link = document.createElement("a"); + link.className = "ytpf-link"; + link.href = video.channel.url; + link.textContent = video.channel.name; + td.appendChild(link); + } else { + td.textContent = video.channel.name; + } + break; + case "duration": + if (video.isLive) { + const badge = document.createElement("span"); + badge.className = "ytpf-live"; + badge.textContent = t("badgeLive"); + td.appendChild(badge); + } else { + td.textContent = video.durationText ?? "--"; + } + break; + case "views": + td.textContent = video.viewCountText ?? "--"; + break; + case "published": + td.textContent = video.publishedAt ?? "--"; + break; + case "category": + td.textContent = video.category ?? "--"; + break; + case "addedBy": + td.textContent = video.addedBy ?? "--"; + break; + case "votes": + td.textContent = video.voteCount != null ? String(video.voteCount) : "--"; + break; + } + + return td; +} + function buildRow( video: PlaylistVideo, playlistId: string, - isCollab: boolean, - hasDetails: boolean, + visibleColumns: Column[], ): HTMLTableRowElement { 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 = t("badgeLive"); - tdDuration.appendChild(badge); - } else { - tdDuration.textContent = video.durationText ?? "--"; - } - tr.appendChild(tdDuration); - - // Views - const tdViews = document.createElement("td"); - tdViews.className = "ytpf-td ytpf-col-views"; - tdViews.textContent = video.viewCountText ?? "--"; - tr.appendChild(tdViews); - - if (hasDetails) { - // Published - const tdPublished = document.createElement("td"); - tdPublished.className = "ytpf-td ytpf-col-published"; - tdPublished.textContent = video.publishedAt ?? "--"; - tr.appendChild(tdPublished); - - // Category - const tdCategory = document.createElement("td"); - tdCategory.className = "ytpf-td ytpf-col-category"; - tdCategory.textContent = video.category ?? "--"; - tr.appendChild(tdCategory); - } - - if (isCollab) { - // Added by - const tdAddedBy = document.createElement("td"); - tdAddedBy.className = "ytpf-td ytpf-col-addedby"; - tdAddedBy.textContent = video.addedBy ?? "--"; - tr.appendChild(tdAddedBy); - - // Votes - const tdVotes = document.createElement("td"); - tdVotes.className = "ytpf-td ytpf-col-votes"; - tdVotes.textContent = video.voteCount != null ? String(video.voteCount) : "--"; - tr.appendChild(tdVotes); + for (const col of visibleColumns) { + tr.appendChild(buildCell(video, col, playlistId)); } return tr; @@ -341,12 +365,14 @@ export interface PlaylistTableHandle { updateDetails(updates: DetailUpdate[]): void; } -export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { +export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs): PlaylistTableHandle { const isCollab = data.videos.some( (v) => v.addedBy != null || v.voteCount != null, ); let hasDetails = data.videos.some((v) => v.publishedAt != null || v.category != null); - let columns = getAllColumns(hasDetails).filter((c) => (!c.collab || isCollab) && (!c.detail || hasDetails)); + + let availableColumns = getAllColumns(hasDetails).filter((c) => (!c.collab || isCollab) && (!c.detail || hasDetails)); + let visibleColumns = getVisibleColumns(availableColumns, columnPrefs); const wrapper = document.createElement("div"); wrapper.className = "ytpf-wrapper"; @@ -396,6 +422,92 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { } satisfies Message); }); + // ⋮ kebab menu + const menuAnchor = document.createElement("div"); + menuAnchor.className = "ytpf-menu-anchor"; + + const menuBtn = document.createElement("button"); + menuBtn.className = "ytpf-menu-btn"; + menuBtn.textContent = "\u22EE"; // ⋮ + menuAnchor.appendChild(menuBtn); + + let menuPanel: HTMLElement | null = null; + + function buildMenu(): HTMLElement { + const panel = document.createElement("div"); + panel.className = "ytpf-menu"; + + const heading = document.createElement("div"); + heading.className = "ytpf-menu-heading"; + heading.textContent = t("colSettings"); + panel.appendChild(heading); + + for (const col of availableColumns) { + const item = document.createElement("label"); + item.className = "ytpf-menu-item"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = getColumnVisible(col, columnPrefs); + + const span = document.createElement("span"); + span.textContent = col.label; + + checkbox.addEventListener("change", () => { + if (!columnPrefs[col.key]) columnPrefs[col.key] = { visible: true, width: col.defaultWidth }; + columnPrefs[col.key].visible = checkbox.checked; + onColumnsChanged(); + }); + + item.append(checkbox, span); + panel.appendChild(item); + } + + // Separator + reset + const sep = document.createElement("div"); + sep.className = "ytpf-menu-sep"; + panel.appendChild(sep); + + const resetBtn = document.createElement("button"); + resetBtn.className = "ytpf-menu-action"; + resetBtn.textContent = t("colSettingsReset"); + resetBtn.addEventListener("click", (e) => { + e.stopPropagation(); + for (const key of Object.keys(columnPrefs)) delete columnPrefs[key]; + onColumnsChanged(); + // Sync checkboxes + panel.querySelectorAll('.ytpf-menu-item input[type="checkbox"]').forEach((cb) => { + cb.checked = true; + }); + }); + panel.appendChild(resetBtn); + + return panel; + } + + function closeMenu() { + if (menuPanel) { + menuPanel.remove(); + menuPanel = null; + } + } + + menuBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (menuPanel) { + closeMenu(); + } else { + menuPanel = buildMenu(); + menuAnchor.appendChild(menuPanel); + } + }); + + document.addEventListener("click", (e) => { + if (menuPanel && !menuAnchor.contains(e.target as Node)) { + closeMenu(); + } + }); + header.append(titleSpan, metaSpan, fetchBtn, toggle); wrapper.appendChild(header); @@ -410,12 +522,10 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { titleInput.placeholder = t("filterTitle"); filters.appendChild(titleInput); - // Channel tag input const channelNames = [...new Set(data.videos.map((v) => v.channel.name))].sort(); const channelTagInput = createTagInput(t("filterChannel"), channelNames, () => applyFilters()); filters.appendChild(channelTagInput.container); - // Category tag input (shown after detail fetch) let categoryTagInput: TagInput | null = null; const categoryFilterContainer = document.createElement("div"); categoryFilterContainer.style.display = "none"; @@ -432,7 +542,6 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { categoryFilterContainer.style.display = ""; } - // Added-by tag input (collab only) let addedByTagInput: TagInput | null = null; if (isCollab) { const addedByNames = [...new Set( @@ -446,6 +555,8 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { filterCount.className = "ytpf-filter-count"; filters.appendChild(filterCount); + filters.appendChild(menuAnchor); + wrapper.appendChild(filters); // Body (table container) @@ -455,19 +566,80 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { const table = document.createElement("table"); table.className = "ytpf-table"; + // Sort state (declared early for buildThead → updateSortIndicators) + let sortKey: SortKey | null = null; + let sortDir: SortDir = "asc"; + // Thead const thead = document.createElement("thead"); const headRow = document.createElement("tr"); - const thElements: HTMLTableCellElement[] = []; + let thElements: HTMLTableCellElement[] = []; - for (const col of columns) { - const th = document.createElement("th"); - th.className = `ytpf-th ytpf-th--sortable ${col.cls}`; - th.dataset.sortKey = col.key; - th.textContent = col.label; - thElements.push(th); - headRow.appendChild(th); + function attachResizeHandles() { + for (let i = 0; i < thElements.length - 1; i++) { + const handle = document.createElement("div"); + handle.className = "ytpf-resize-handle"; + thElements[i].appendChild(handle); + + const idx = i; + handle.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + handle.classList.add("ytpf-resize-handle--active"); + + const startX = e.clientX; + const tableWidth = table.getBoundingClientRect().width; + const startLeftPx = thElements[idx].getBoundingClientRect().width; + const startRightPx = thElements[idx + 1].getBoundingClientRect().width; + const minPx = 30; + + function onMouseMove(ev: MouseEvent) { + const delta = ev.clientX - startX; + const newLeftPx = Math.max(minPx, startLeftPx + delta); + const newRightPx = Math.max(minPx, startRightPx - delta); + thElements[idx].style.width = `${(newLeftPx / tableWidth) * 100}%`; + thElements[idx + 1].style.width = `${(newRightPx / tableWidth) * 100}%`; + } + + function onMouseUp() { + handle.classList.remove("ytpf-resize-handle--active"); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + + // Persist all visible column widths + const tw = table.getBoundingClientRect().width; + for (let j = 0; j < visibleColumns.length; j++) { + const col = visibleColumns[j]; + const pct = (thElements[j].getBoundingClientRect().width / tw) * 100; + if (!columnPrefs[col.key]) columnPrefs[col.key] = { visible: true, width: col.defaultWidth }; + columnPrefs[col.key].width = Math.round(pct * 10) / 10; + } + saveColumnPrefs(columnPrefs); + } + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); + } } + + function buildThead() { + headRow.textContent = ""; + thElements = []; + for (const col of visibleColumns) { + const th = document.createElement("th"); + th.className = `ytpf-th ytpf-th--sortable ${col.cls}`; + th.dataset.sortKey = col.key; + th.textContent = col.label; + thElements.push(th); + headRow.appendChild(th); + } + applyColumnWidths(thElements, visibleColumns, columnPrefs); + attachResizeHandles(); + updateSortIndicators(); + } + + buildThead(); thead.appendChild(headRow); table.appendChild(thead); @@ -495,7 +667,7 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { const filtered = getFilteredVideos(); tbody.textContent = ""; for (const video of filtered) { - tbody.appendChild(buildRow(video, playlistId, isCollab, hasDetails)); + tbody.appendChild(buildRow(video, playlistId, visibleColumns)); } filterCount.textContent = filtered.length < videos.length ? `${filtered.length} / ${videos.length}` @@ -510,10 +682,6 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { renderRows(); - // Sort state - let sortKey: SortKey | null = null; - let sortDir: SortDir = "asc"; - function updateSortIndicators() { for (const th of thElements) { const key = th.dataset.sortKey; @@ -544,6 +712,14 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { renderRows(); }); + // Column visibility/width change handler + function onColumnsChanged() { + visibleColumns = getVisibleColumns(availableColumns, columnPrefs); + buildThead(); + renderRows(); + saveColumnPrefs(columnPrefs); + } + table.appendChild(tbody); body.appendChild(table); wrapper.appendChild(body); @@ -559,7 +735,6 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { videos.push(...newVideos); extractedCount += newVideos.length; - // Update filter candidates channelTagInput.addCandidates(newVideos.map((v) => v.channel.name)); if (addedByTagInput) { addedByTagInput.addCandidates( @@ -567,7 +742,6 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { ); } - // Re-sort if a sort is active if (sortKey) { videos.sort(compareFn(sortKey, sortDir)); } @@ -600,25 +774,19 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { detailsTotal = videos.filter((v) => v.isPlayable).length; } - // Add detail columns on first update if not present if (!hasDetails) { hasDetails = true; - columns = getAllColumns(true).filter((c) => (!c.collab || isCollab) && (!c.detail || hasDetails)); - // Rebuild thead - headRow.textContent = ""; - thElements.length = 0; - for (const col of columns) { - const th = document.createElement("th"); - th.className = `ytpf-th ytpf-th--sortable ${col.cls}`; - th.dataset.sortKey = col.key; - th.textContent = col.label; - thElements.push(th); - headRow.appendChild(th); + availableColumns = getAllColumns(true).filter((c) => (!c.collab || isCollab) && (!c.detail || hasDetails)); + visibleColumns = getVisibleColumns(availableColumns, columnPrefs); + buildThead(); + // Rebuild menu if open + if (menuPanel) { + closeMenu(); + menuPanel = buildMenu(); + menuAnchor.appendChild(menuPanel); } - updateSortIndicators(); } - // Update category filter ensureCategoryFilter(); if (categoryTagInput) { categoryTagInput.addCandidates( @@ -626,7 +794,6 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle { ); } - // Update progress if (detailsReceived < detailsTotal) { fetchBtn.textContent = `${t("fetchViewsProgress")} ${detailsReceived}/${detailsTotal}`; } else {