diff --git a/src/content/ui/i18n.ts b/src/content/ui/i18n.ts index d9caaf4..5167a1e 100644 --- a/src/content/ui/i18n.ts +++ b/src/content/ui/i18n.ts @@ -19,7 +19,11 @@ type MessageKey = | "fetchViewsProgress" | "fetchViewsDone" | "colSettings" - | "colSettingsReset"; + | "colSettingsReset" + | "statsDuration" + | "statsChannels" + | "statsPlayable" + | "statsTotalViews"; const messages: Record> = { ja: { @@ -44,6 +48,10 @@ const messages: Record> = { fetchViewsDone: "取得完了", colSettings: "表示", colSettingsReset: "リセット", + statsDuration: "合計時間", + statsChannels: "チャンネル", + statsPlayable: "再生可能", + statsTotalViews: "総再生数", }, en: { colIndex: "#", @@ -67,6 +75,10 @@ const messages: Record> = { fetchViewsDone: "Done", colSettings: "View", colSettingsReset: "Reset", + statsDuration: "Total", + statsChannels: "Channels", + statsPlayable: "Playable", + statsTotalViews: "Total views", }, }; diff --git a/src/content/ui/styles.ts b/src/content/ui/styles.ts index 974faaa..ed56511 100644 --- a/src/content/ui/styles.ts +++ b/src/content/ui/styles.ts @@ -49,6 +49,46 @@ html[dark] .ytpf-toggle { transform: rotate(-90deg); } +.ytpf-stats { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 16px; + flex-wrap: wrap; + border-bottom: 1px solid var(--yt-spec-10-percent-layer, rgba(0,0,0,0.06)); +} + +html[dark] .ytpf-stats { + border-bottom-color: rgba(255,255,255,0.06); +} + +.ytpf-stats--hidden { + display: none; +} + +.ytpf-stat { + font-size: 12px; + color: var(--yt-spec-text-secondary, #606060); + white-space: nowrap; +} + +html[dark] .ytpf-stat { + color: #aaa; +} + +.ytpf-stat-value { + font-family: "Roboto Mono", monospace; + color: var(--yt-spec-text-primary, #0f0f0f); +} + +html[dark] .ytpf-stat-value { + color: #f1f1f1; +} + +.ytpf-stats-spacer { + flex: 1; +} + .ytpf-filters { display: flex; align-items: flex-start; diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts index e73c1a7..50cac92 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -393,35 +393,10 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs } 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"; @@ -508,9 +483,87 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs } }); - header.append(titleSpan, metaSpan, fetchBtn, toggle); + header.append(titleSpan, toggle); wrapper.appendChild(header); + // Stats bar (between header and filters) + const statsBar = document.createElement("div"); + statsBar.className = "ytpf-stats"; + + function makeStat(label: string, value: string): HTMLElement { + const span = document.createElement("span"); + span.className = "ytpf-stat"; + span.textContent = `${label}: `; + const val = document.createElement("span"); + val.className = "ytpf-stat-value"; + val.textContent = value; + span.appendChild(val); + return span; + } + + function formatNumber(n: number): string { + return n.toLocaleString(); + } + + function formatDuration(totalSec: number): string { + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; + } + + const statDuration = makeStat(t("statsDuration"), data.metadata.totalDurationText ?? "--"); + const statChannels = makeStat(t("statsChannels"), "0"); + const statPlayable = makeStat(t("statsPlayable"), "0"); + const statTotalViews = makeStat(t("statsTotalViews"), "--"); + statTotalViews.style.display = "none"; + + function updateStats() { + const channelSet = new Set(videos.map((v) => v.channel.name)); + statChannels.querySelector(".ytpf-stat-value")!.textContent = String(channelSet.size); + const playable = videos.filter((v) => v.isPlayable).length; + statPlayable.querySelector(".ytpf-stat-value")!.textContent = + playable === videos.length ? String(playable) : `${playable}/${videos.length}`; + // Duration from individual videos (more accurate than metadata when appending) + const totalSec = videos.reduce((sum, v) => sum + (v.durationSeconds ?? 0), 0); + if (totalSec > 0) { + statDuration.querySelector(".ytpf-stat-value")!.textContent = formatDuration(totalSec); + } + // Total views (only when some videos have viewCountText) + const viewCounts = videos.map((v) => parseViewCount(v.viewCountText)).filter((n) => n >= 0); + if (viewCounts.length > 0) { + const total = viewCounts.reduce((a, b) => a + b, 0); + statTotalViews.querySelector(".ytpf-stat-value")!.textContent = formatNumber(total); + statTotalViews.style.display = ""; + } + } + + const statsSpacer = document.createElement("span"); + statsSpacer.className = "ytpf-stats-spacer"; + + // 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); + }); + + statsBar.append(statDuration, statChannels, statPlayable, statTotalViews, statsSpacer, fetchBtn); + wrapper.appendChild(statsBar); + // Filter bar const filters = document.createElement("div"); filters.className = "ytpf-filters"; @@ -681,6 +734,7 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs titleInput.addEventListener("input", applyFilters); renderRows(); + updateStats(); function updateSortIndicators() { for (const th of thElements) { @@ -727,6 +781,7 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs // Toggle collapse header.addEventListener("click", () => { body.classList.toggle("ytpf-body--hidden"); + statsBar.classList.toggle("ytpf-stats--hidden"); filters.classList.toggle("ytpf-filters--hidden"); toggle.classList.toggle("ytpf-toggle--collapsed"); }); @@ -747,6 +802,7 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs } renderRows(); updateHeader(); + updateStats(); } function setComplete(count: number) { @@ -805,6 +861,7 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs videos.sort(compareFn(sortKey, sortDir)); } renderRows(); + updateStats(); } return { element: wrapper, appendVideos, setComplete, updateDetails };