diff --git a/src/content/ui/i18n.ts b/src/content/ui/i18n.ts index 5167a1e..4eb8389 100644 --- a/src/content/ui/i18n.ts +++ b/src/content/ui/i18n.ts @@ -23,7 +23,14 @@ type MessageKey = | "statsDuration" | "statsChannels" | "statsPlayable" - | "statsTotalViews"; + | "statsTotalViews" + | "statsDetail" + | "statsDetailChannelRank" + | "statsDetailAddedByRank" + | "statsDetailCategoryBreak" + | "statsDetailDurationAvg" + | "statsDetailDurationMedian" + | "statsDetailVideos"; const messages: Record> = { ja: { @@ -43,7 +50,7 @@ const messages: Record> = { badgeLive: "ライブ", headerVideos: "本の動画", headerLoading: "読み込み中…", - fetchViews: "再生数を取得", + fetchViews: "全件詳細を取得", fetchViewsProgress: "取得中…", fetchViewsDone: "取得完了", colSettings: "表示", @@ -52,6 +59,13 @@ const messages: Record> = { statsChannels: "チャンネル", statsPlayable: "再生可能", statsTotalViews: "総再生数", + statsDetail: "詳細", + statsDetailChannelRank: "チャンネル別", + statsDetailAddedByRank: "追加者別", + statsDetailCategoryBreak: "カテゴリ別", + statsDetailDurationAvg: "平均再生時間", + statsDetailDurationMedian: "中央値", + statsDetailVideos: "本", }, en: { colIndex: "#", @@ -70,7 +84,7 @@ const messages: Record> = { badgeLive: "LIVE", headerVideos: "videos", headerLoading: "loading…", - fetchViews: "Fetch views", + fetchViews: "Fetch all details", fetchViewsProgress: "Fetching…", fetchViewsDone: "Done", colSettings: "View", @@ -79,6 +93,13 @@ const messages: Record> = { statsChannels: "Channels", statsPlayable: "Playable", statsTotalViews: "Total views", + statsDetail: "Details", + statsDetailChannelRank: "By channel", + statsDetailAddedByRank: "By contributor", + statsDetailCategoryBreak: "By category", + statsDetailDurationAvg: "Avg. duration", + statsDetailDurationMedian: "Median", + statsDetailVideos: "videos", }, }; diff --git a/src/content/ui/styles.ts b/src/content/ui/styles.ts index ed56511..7c0a51e 100644 --- a/src/content/ui/styles.ts +++ b/src/content/ui/styles.ts @@ -85,10 +85,172 @@ html[dark] .ytpf-stat-value { color: #f1f1f1; } +.ytpf-stats-detail-btn { + padding: 2px 8px; + border: 1px solid var(--yt-spec-10-percent-layer, rgba(0,0,0,0.2)); + border-radius: 12px; + background: transparent; + color: var(--yt-spec-text-secondary, #606060); + font-size: 11px; + font-family: "Roboto", "Arial", sans-serif; + cursor: pointer; + white-space: nowrap; +} + +html[dark] .ytpf-stats-detail-btn { + border-color: rgba(255,255,255,0.2); + color: #aaa; +} + +.ytpf-stats-detail-btn:hover { + background: var(--yt-spec-badge-chip-background, #f2f2f2); +} + +html[dark] .ytpf-stats-detail-btn:hover { + background: #3e3e3e; +} + .ytpf-stats-spacer { flex: 1; } +.ytpf-detail-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: #fff; + border: 1px solid rgba(0,0,0,0.1); + border-radius: 12px; + padding: 20px 24px; + min-width: 360px; + max-width: 560px; + max-height: 70vh; + overflow-y: auto; + z-index: 3000; + box-shadow: 0 8px 24px rgba(0,0,0,0.2); + font-family: "Roboto", "Arial", sans-serif; + font-size: 13px; + color: var(--yt-spec-text-primary, #0f0f0f); +} + +html[dark] .ytpf-detail-popup { + background: #212121; + border-color: rgba(255,255,255,0.1); + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + color: #f1f1f1; +} + +.ytpf-detail-popup-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + z-index: 2999; +} + +.ytpf-detail-popup h3 { + font-size: 15px; + font-weight: 500; + margin: 0 0 16px; +} + +.ytpf-detail-section { + margin-bottom: 16px; +} + +.ytpf-detail-section:last-child { + margin-bottom: 0; +} + +.ytpf-detail-section-title { + font-size: 12px; + font-weight: 500; + color: var(--yt-spec-text-secondary, #606060); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +html[dark] .ytpf-detail-section-title { + color: #aaa; +} + +.ytpf-detail-bar-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 3px; + font-size: 12px; +} + +.ytpf-detail-bar-label { + width: 140px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ytpf-detail-bar-track { + flex: 1; + height: 14px; + background: var(--yt-spec-10-percent-layer, rgba(0,0,0,0.06)); + border-radius: 3px; + overflow: hidden; +} + +html[dark] .ytpf-detail-bar-track { + background: rgba(255,255,255,0.08); +} + +.ytpf-detail-bar-fill { + height: 100%; + border-radius: 3px; + background: var(--yt-spec-call-to-action, #065fd4); + opacity: 0.7; +} + +html[dark] .ytpf-detail-bar-fill { + background: #3ea6ff; +} + +.ytpf-detail-bar-count { + width: 48px; + flex-shrink: 0; + text-align: right; + font-family: "Roboto Mono", monospace; + font-size: 11px; + color: var(--yt-spec-text-secondary, #606060); +} + +html[dark] .ytpf-detail-bar-count { + color: #aaa; +} + +.ytpf-detail-kv { + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 12px; +} + +.ytpf-detail-kv-item { + color: var(--yt-spec-text-secondary, #606060); +} + +html[dark] .ytpf-detail-kv-item { + color: #aaa; +} + +.ytpf-detail-kv-value { + font-family: "Roboto Mono", monospace; + color: var(--yt-spec-text-primary, #0f0f0f); +} + +html[dark] .ytpf-detail-kv-value { + color: #f1f1f1; +} + .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 50cac92..77b3e35 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -539,6 +539,154 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs } } + // Stats detail button + popup + const detailBtn = document.createElement("button"); + detailBtn.className = "ytpf-stats-detail-btn"; + detailBtn.textContent = t("statsDetail"); + + function countBy(arr: T[], keyFn: (item: T) => string | null): [string, number][] { + const map = new Map(); + for (const item of arr) { + const key = keyFn(item); + if (key != null) map.set(key, (map.get(key) ?? 0) + 1); + } + return [...map.entries()].sort((a, b) => b[1] - a[1]); + } + + function buildDetailPopup(): { backdrop: HTMLElement; popup: HTMLElement } { + const backdrop = document.createElement("div"); + backdrop.className = "ytpf-detail-popup-backdrop"; + + const popup = document.createElement("div"); + popup.className = "ytpf-detail-popup"; + + const title = document.createElement("h3"); + title.textContent = `${data.metadata.title} — ${t("statsDetail")}`; + popup.appendChild(title); + + // Duration stats + const durations = videos + .map((v) => v.durationSeconds) + .filter((d): d is number => d != null && d > 0) + .sort((a, b) => a - b); + + if (durations.length > 0) { + const avg = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length); + const median = durations[Math.floor(durations.length / 2)]; + + const section = document.createElement("div"); + section.className = "ytpf-detail-section"; + const kv = document.createElement("div"); + kv.className = "ytpf-detail-kv"; + + function addKV(label: string, value: string) { + const item = document.createElement("span"); + item.className = "ytpf-detail-kv-item"; + item.textContent = `${label}: `; + const val = document.createElement("span"); + val.className = "ytpf-detail-kv-value"; + val.textContent = value; + item.appendChild(val); + kv.appendChild(item); + } + + addKV(t("statsDetailDurationAvg"), formatDuration(avg)); + addKV(t("statsDetailDurationMedian"), formatDuration(median)); + section.appendChild(kv); + popup.appendChild(section); + } + + // Channel ranking + const channelCounts = countBy(videos, (v) => v.channel.name); + if (channelCounts.length > 0) { + popup.appendChild(buildBarSection( + t("statsDetailChannelRank"), + channelCounts.slice(0, 20), + channelCounts[0][1], + )); + } + + // Added-by ranking (collab) + if (isCollab) { + const addedByCounts = countBy(videos, (v) => v.addedBy); + if (addedByCounts.length > 0) { + popup.appendChild(buildBarSection( + t("statsDetailAddedByRank"), + addedByCounts, + addedByCounts[0][1], + )); + } + } + + // Category breakdown (after detail fetch) + const categoryCounts = countBy(videos, (v) => v.category); + if (categoryCounts.length > 0) { + popup.appendChild(buildBarSection( + t("statsDetailCategoryBreak"), + categoryCounts, + categoryCounts[0][1], + )); + } + + return { backdrop, popup }; + } + + function buildBarSection(title: string, entries: [string, number][], maxCount: number): HTMLElement { + const section = document.createElement("div"); + section.className = "ytpf-detail-section"; + + const heading = document.createElement("div"); + heading.className = "ytpf-detail-section-title"; + heading.textContent = title; + section.appendChild(heading); + + for (const [name, count] of entries) { + const row = document.createElement("div"); + row.className = "ytpf-detail-bar-row"; + + const label = document.createElement("span"); + label.className = "ytpf-detail-bar-label"; + label.textContent = name; + label.title = name; + + const track = document.createElement("div"); + track.className = "ytpf-detail-bar-track"; + const fill = document.createElement("div"); + fill.className = "ytpf-detail-bar-fill"; + fill.style.width = `${(count / maxCount) * 100}%`; + track.appendChild(fill); + + const countSpan = document.createElement("span"); + countSpan.className = "ytpf-detail-bar-count"; + countSpan.textContent = String(count); + + row.append(label, track, countSpan); + section.appendChild(row); + } + + return section; + } + + let detailPopupOpen = false; + + detailBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (detailPopupOpen) return; + detailPopupOpen = true; + + const { backdrop, popup } = buildDetailPopup(); + + function close() { + backdrop.remove(); + popup.remove(); + detailPopupOpen = false; + } + + backdrop.addEventListener("click", close); + document.body.appendChild(backdrop); + document.body.appendChild(popup); + }); + const statsSpacer = document.createElement("span"); statsSpacer.className = "ytpf-stats-spacer"; @@ -561,7 +709,7 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs } satisfies Message); }); - statsBar.append(statDuration, statChannels, statPlayable, statTotalViews, statsSpacer, fetchBtn); + statsBar.append(statDuration, statChannels, statPlayable, statTotalViews, detailBtn, statsSpacer, fetchBtn); wrapper.appendChild(statsBar); // Filter bar