import browser from "webextension-polyfill"; import type { PlaylistData, PlaylistVideo, DetailUpdate } from "../../types/playlist"; import type { Message } from "../../shared/messages"; import { t } from "./i18n"; type SortKey = "index" | "title" | "channel" | "duration" | "views" | "published" | "category" | "addedBy" | "votes"; type SortDir = "asc" | "desc"; interface Column { label: string; cls: string; key: SortKey; collab?: boolean; detail?: boolean; defaultWidth: number; // default width in % } function getAllColumns(hasDetails: boolean): Column[] { return [ { 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[]; addCandidates(names: string[]): void; } function createTagInput( placeholder: string, initialCandidates: string[], onChange: () => void, ): TagInput { const candidates = [...initialCandidates]; const tags: string[] = []; const container = document.createElement("div"); container.className = "ytpf-tag-input"; const inputId = `ytpf-tag-${placeholder.replace(/[^a-zA-Z]/g, "")}`; const input = document.createElement("input"); input.className = "ytpf-tag-text-input"; input.type = "text"; input.name = inputId; input.placeholder = placeholder; function updatePlaceholder() { input.placeholder = tags.length > 0 ? "" : placeholder; } const dropdown = document.createElement("div"); dropdown.className = "ytpf-autocomplete"; dropdown.style.display = "none"; let activeIndex = -1; function tagHue(name: string): number { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } return ((hash % 360) + 360) % 360; } function addTag(name: string) { if (tags.includes(name)) return; tags.push(name); const hue = tagHue(name); const isDark = document.documentElement.hasAttribute("dark"); const tag = document.createElement("span"); tag.className = "ytpf-tag"; tag.dataset.value = name; tag.style.background = isDark ? `hsl(${hue} 40% 25%)` : `hsl(${hue} 55% 90%)`; tag.style.color = isDark ? `hsl(${hue} 60% 80%)` : `hsl(${hue} 60% 30%)`; const label = document.createElement("span"); label.textContent = name; const remove = document.createElement("span"); remove.className = "ytpf-tag-remove"; remove.textContent = "\u00d7"; remove.addEventListener("click", (e) => { e.stopPropagation(); const idx = tags.indexOf(name); if (idx !== -1) tags.splice(idx, 1); tag.remove(); updatePlaceholder(); onChange(); }); tag.append(label, remove); container.insertBefore(tag, input); input.value = ""; updatePlaceholder(); showDropdown(""); input.focus(); onChange(); } function showDropdown(filter: string) { const query = filter.toLowerCase(); const matches = candidates.filter( (c) => !tags.includes(c) && c.toLowerCase().includes(query), ); if (matches.length === 0) { hideDropdown(); return; } dropdown.textContent = ""; activeIndex = -1; for (const name of matches.slice(0, 20)) { const item = document.createElement("div"); item.className = "ytpf-autocomplete-item"; item.textContent = name; item.addEventListener("mousedown", (e) => { e.preventDefault(); addTag(name); }); dropdown.appendChild(item); } dropdown.style.display = ""; } function hideDropdown() { dropdown.style.display = "none"; activeIndex = -1; } function updateActive() { const items = dropdown.querySelectorAll(".ytpf-autocomplete-item"); items.forEach((el, i) => { el.classList.toggle("ytpf-autocomplete-item--active", i === activeIndex); }); if (activeIndex >= 0 && items[activeIndex]) { items[activeIndex].scrollIntoView({ block: "nearest" }); } } input.addEventListener("input", () => { showDropdown(input.value.trim()); }); input.addEventListener("focus", () => { showDropdown(input.value.trim()); }); input.addEventListener("keydown", (e) => { const items = dropdown.querySelectorAll(".ytpf-autocomplete-item"); if (e.key === "ArrowDown") { e.preventDefault(); if (items.length > 0) { activeIndex = Math.min(activeIndex + 1, items.length - 1); updateActive(); } } else if (e.key === "ArrowUp") { e.preventDefault(); if (items.length > 0) { activeIndex = Math.max(activeIndex - 1, 0); updateActive(); } } else if (e.key === "Enter") { e.preventDefault(); if (activeIndex >= 0 && items[activeIndex]) { addTag(items[activeIndex].textContent!); } else if (input.value.trim() && items.length > 0) { addTag(items[0].textContent!); } } else if (e.key === "Backspace" && !input.value && tags.length > 0) { const last = tags.pop()!; container.querySelector(`.ytpf-tag[data-value="${CSS.escape(last)}"]`)?.remove(); updatePlaceholder(); onChange(); } }); input.addEventListener("blur", () => { setTimeout(hideDropdown, 150); }); container.addEventListener("click", () => input.focus()); container.append(input, dropdown); return { container, getTags: () => [...tags], addCandidates(names: string[]) { for (const name of names) { if (!candidates.includes(name)) candidates.push(name); } candidates.sort(); }, }; } function parseViewCount(text: string | null): number { if (!text) return -1; const cleaned = text.replace(/,/g, ""); const m = cleaned.match(/([\d.]+)\s*(万|億)?/); if (!m) return -1; let num = parseFloat(m[1]); if (m[2] === "万") num *= 10000; else if (m[2] === "億") num *= 100000000; return Math.round(num); } function compareFn(key: SortKey, dir: SortDir) { const m = dir === "asc" ? 1 : -1; return (a: PlaylistVideo, b: PlaylistVideo): number => { switch (key) { case "index": return (a.index - b.index) * m; case "title": return a.title.localeCompare(b.title) * m; case "channel": return a.channel.name.localeCompare(b.channel.name) * m; case "duration": return ((a.durationSeconds ?? -1) - (b.durationSeconds ?? -1)) * m; case "views": return (parseViewCount(a.viewCountText) - parseViewCount(b.viewCountText)) * m; case "published": return (a.publishedAt ?? "").localeCompare(b.publishedAt ?? "") * m; case "category": return (a.category ?? "").localeCompare(b.category ?? "") * m; case "addedBy": return (a.addedBy ?? "").localeCompare(b.addedBy ?? "") * m; case "votes": return ((a.voteCount ?? 0) - (b.voteCount ?? 0)) * m; } }; } 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, visibleColumns: Column[], ): HTMLTableRowElement { const tr = document.createElement("tr"); tr.className = "ytpf-tr"; if (!video.isPlayable) tr.classList.add("ytpf-tr--unplayable"); for (const col of visibleColumns) { tr.appendChild(buildCell(video, col, playlistId)); } return tr; } export interface PlaylistTableHandle { element: HTMLElement; appendVideos(newVideos: PlaylistVideo[]): void; setComplete(extractedCount: number): void; updateDetails(updates: DetailUpdate[]): void; } 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 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"; // Header bar 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"; 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"; if (data.metadata.totalDurationText) { metaSpan.textContent = data.metadata.totalDurationText; } const toggle = document.createElement("span"); toggle.className = "ytpf-toggle"; toggle.textContent = "\u25BC"; // Fetch details button const fetchBtn = document.createElement("button"); fetchBtn.className = "ytpf-fetch-views-btn"; fetchBtn.textContent = t("fetchViews"); let detailsFetched = false; fetchBtn.addEventListener("click", (e) => { e.stopPropagation(); if (detailsFetched) return; const targetIds = videos.filter((v) => v.isPlayable).map((v) => v.videoId); if (targetIds.length === 0) return; detailsFetched = true; fetchBtn.disabled = true; fetchBtn.textContent = `${t("fetchViewsProgress")} 0/${targetIds.length}`; browser.runtime.sendMessage({ type: "FETCH_VIDEO_DETAILS", videoIds: targetIds, } 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); // Filter bar const filters = document.createElement("div"); filters.className = "ytpf-filters"; const titleInput = document.createElement("input"); titleInput.className = "ytpf-filter-input"; titleInput.type = "text"; titleInput.name = "ytpf-title-filter"; titleInput.placeholder = t("filterTitle"); filters.appendChild(titleInput); const channelNames = [...new Set(data.videos.map((v) => v.channel.name))].sort(); const channelTagInput = createTagInput(t("filterChannel"), channelNames, () => applyFilters()); filters.appendChild(channelTagInput.container); let categoryTagInput: TagInput | null = null; const categoryFilterContainer = document.createElement("div"); categoryFilterContainer.style.display = "none"; filters.appendChild(categoryFilterContainer); function ensureCategoryFilter() { if (categoryTagInput) return; const categoryNames = [...new Set( videos.map((v) => v.category).filter((c): c is string => c != null), )].sort(); if (categoryNames.length === 0) return; categoryTagInput = createTagInput(t("filterCategory"), categoryNames, () => applyFilters()); categoryFilterContainer.appendChild(categoryTagInput.container); categoryFilterContainer.style.display = ""; } let addedByTagInput: TagInput | null = null; if (isCollab) { const addedByNames = [...new Set( data.videos.map((v) => v.addedBy).filter((n): n is string => n != null), )].sort(); addedByTagInput = createTagInput(t("filterAddedBy"), addedByNames, () => applyFilters()); filters.appendChild(addedByTagInput.container); } const filterCount = document.createElement("span"); filterCount.className = "ytpf-filter-count"; filters.appendChild(filterCount); filters.appendChild(menuAnchor); wrapper.appendChild(filters); // Body (table container) const body = document.createElement("div"); body.className = "ytpf-body"; 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"); let thElements: HTMLTableCellElement[] = []; 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); // Tbody const tbody = document.createElement("tbody"); const playlistId = data.metadata.playlistId; const videos = [...data.videos]; function getFilteredVideos(): PlaylistVideo[] { const titleQuery = titleInput.value.toLowerCase(); const channelTags = channelTagInput.getTags(); const categoryTags = categoryTagInput?.getTags() ?? []; const addedByTags = addedByTagInput?.getTags() ?? []; return videos.filter((v) => { if (titleQuery && !v.title.toLowerCase().includes(titleQuery)) return false; if (channelTags.length > 0 && !channelTags.includes(v.channel.name)) return false; if (categoryTags.length > 0 && (!v.category || !categoryTags.includes(v.category))) return false; if (addedByTags.length > 0 && (!v.addedBy || !addedByTags.includes(v.addedBy))) return false; return true; }); } function renderRows() { const filtered = getFilteredVideos(); tbody.textContent = ""; for (const video of filtered) { tbody.appendChild(buildRow(video, playlistId, visibleColumns)); } filterCount.textContent = filtered.length < videos.length ? `${filtered.length} / ${videos.length}` : ""; } function applyFilters() { renderRows(); } titleInput.addEventListener("input", applyFilters); renderRows(); function updateSortIndicators() { for (const th of thElements) { const key = th.dataset.sortKey; if (key === sortKey) { th.dataset.sortDir = sortDir; } else { delete th.dataset.sortDir; } } } headRow.addEventListener("click", (e) => { const th = (e.target as HTMLElement).closest( "th[data-sort-key]", ); if (!th) return; const key = th.dataset.sortKey as SortKey; if (sortKey === key) { sortDir = sortDir === "asc" ? "desc" : "asc"; } else { sortKey = key; sortDir = "asc"; } videos.sort(compareFn(sortKey, sortDir)); updateSortIndicators(); 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); // Toggle collapse header.addEventListener("click", () => { body.classList.toggle("ytpf-body--hidden"); filters.classList.toggle("ytpf-filters--hidden"); toggle.classList.toggle("ytpf-toggle--collapsed"); }); function appendVideos(newVideos: PlaylistVideo[]) { videos.push(...newVideos); extractedCount += newVideos.length; channelTagInput.addCandidates(newVideos.map((v) => v.channel.name)); if (addedByTagInput) { addedByTagInput.addCandidates( newVideos.map((v) => v.addedBy).filter((n): n is string => n != null), ); } if (sortKey) { videos.sort(compareFn(sortKey, sortDir)); } renderRows(); updateHeader(); } function setComplete(count: number) { extractedCount = count; complete = true; updateHeader(); } let detailsReceived = 0; let detailsTotal = 0; function updateDetails(updates: DetailUpdate[]) { const map = new Map(updates.map((u) => [u.videoId, u])); for (const v of videos) { const u = map.get(v.videoId); if (u) { v.viewCountText = u.viewCountText ?? v.viewCountText; v.publishedAt = u.publishedAt; v.category = u.category; } } detailsReceived += updates.length; if (detailsTotal === 0) { detailsTotal = videos.filter((v) => v.isPlayable).length; } if (!hasDetails) { hasDetails = true; 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); } } ensureCategoryFilter(); if (categoryTagInput) { categoryTagInput.addCandidates( updates.map((u) => u.category).filter((c): c is string => c != null), ); } if (detailsReceived < detailsTotal) { fetchBtn.textContent = `${t("fetchViewsProgress")} ${detailsReceived}/${detailsTotal}`; } else { fetchBtn.textContent = t("fetchViewsDone"); setTimeout(() => { fetchBtn.style.display = "none"; }, 1500); } if (sortKey) { videos.sort(compareFn(sortKey, sortDir)); } renderRows(); } return { element: wrapper, appendVideos, setComplete, updateDetails }; }