diff --git a/src/content/index.ts b/src/content/index.ts index 09b3c4f..092e4b1 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -3,6 +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"; const LOG = "[yt-playlist-features]"; @@ -79,6 +80,8 @@ function handlePlaylistData(event: Event): void { `Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount}/${metadata.videoCount} videos)`, ); + mountTable(playlistData); + const message: Message = { type: "PLAYLIST_EXTRACTED", data: playlistData }; browser.runtime.sendMessage(message).catch((err) => { console.error(LOG, "Failed to send playlist data to background:", err); diff --git a/src/content/ui/lifecycle.ts b/src/content/ui/lifecycle.ts new file mode 100644 index 0000000..8bb249d --- /dev/null +++ b/src/content/ui/lifecycle.ts @@ -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); diff --git a/src/content/ui/styles.ts b/src/content/ui/styles.ts new file mode 100644 index 0000000..6aea973 --- /dev/null +++ b/src/content/ui/styles.ts @@ -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(); +} diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts new file mode 100644 index 0000000..b74d5e9 --- /dev/null +++ b/src/content/ui/table-renderer.ts @@ -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; +}