From 3635b58fedee476dbf8c1e45fa5c931a875e7f94 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Apr 2026 17:14:32 +0900 Subject: [PATCH] =?UTF-8?q?Vote=E3=81=AE=E3=82=B5=E3=83=9D=E3=83=BC?= =?UTF-8?q?=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/extractor.ts | 2 + src/content/index.ts | 5 +- src/content/ui/lifecycle.ts | 4 + src/content/ui/styles.ts | 65 ++++++++++++++- src/content/ui/table-renderer.ts | 139 +++++++++++++++++++++++++++++-- src/injected/page-script.ts | 130 +++++++++++++++++++++++++++++ src/types/playlist.ts | 4 + 7 files changed, 342 insertions(+), 7 deletions(-) diff --git a/src/content/extractor.ts b/src/content/extractor.ts index 9f6d81a..aa4cc13 100644 --- a/src/content/extractor.ts +++ b/src/content/extractor.ts @@ -181,6 +181,8 @@ function parseVideo(renderer: any): PlaylistVideo | null { addedBy: null, voteCount: null, likeCount: null, + setVideoId: null, + voteStatus: null, }; } diff --git a/src/content/index.ts b/src/content/index.ts index 37caa35..969da66 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -3,7 +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, appendToTable, setTableComplete, updateTableDetails } from "./ui/lifecycle"; +import { mountTable, appendToTable, setTableComplete, updateTableDetails, updateVoteStatuses } from "./ui/lifecycle"; const LOG = "[yt-playlist-features]"; @@ -38,6 +38,8 @@ function mapRawVideo(v: any): PlaylistVideo { addedBy: v.addedBy ?? null, voteCount: v.voteCount ?? null, likeCount: v.likeCount ?? null, + setVideoId: v.setVideoId ?? null, + voteStatus: v.voteStatus ?? null, } satisfies PlaylistVideo; } @@ -122,6 +124,7 @@ function handlePlaylistAppend(event: Event): void { document.addEventListener("__yt_playlist_ext_data", handlePlaylistData); document.addEventListener("__yt_playlist_ext_data", handlePlaylistAppend); + // Listen for detail updates from background service worker browser.runtime.onMessage.addListener((message: unknown) => { const msg = message as Message; diff --git a/src/content/ui/lifecycle.ts b/src/content/ui/lifecycle.ts index 2771df8..d4fa24d 100644 --- a/src/content/ui/lifecycle.ts +++ b/src/content/ui/lifecycle.ts @@ -80,6 +80,10 @@ export function updateTableDetails(updates: DetailUpdate[]): void { currentHandle?.updateDetails(updates); } +export function updateVoteStatuses(statuses: { setVideoId: string; voteStatus: "up" | "down" | null }[]): void { + currentHandle?.updateVoteStatuses(statuses); +} + export function unmountTable(): void { if (pendingObserver) { pendingObserver.disconnect(); diff --git a/src/content/ui/styles.ts b/src/content/ui/styles.ts index 1b1e013..cb8d167 100644 --- a/src/content/ui/styles.ts +++ b/src/content/ui/styles.ts @@ -515,7 +515,70 @@ html[dark] .ytpf-tr:hover { .ytpf-col-category { width: 120px; } .ytpf-col-likes { width: 90px; text-align: right; font-family: "Roboto Mono", monospace; } .ytpf-col-addedby { width: 140px; } -.ytpf-col-votes { width: 60px; text-align: center; font-family: "Roboto Mono", monospace; } +.ytpf-col-votes { width: 90px; text-align: center; font-family: "Roboto Mono", monospace; } + +.ytpf-vote { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.ytpf-vote-btn { + padding: 0; + border: none; + background: transparent; + color: var(--yt-spec-text-secondary, #606060); + font-size: 10px; + line-height: 1; + cursor: pointer; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +html[dark] .ytpf-vote-btn { + color: #aaa; +} + +.ytpf-vote-btn:hover:not(:disabled) { + background: var(--yt-spec-badge-chip-background, #f2f2f2); + color: var(--yt-spec-text-primary, #0f0f0f); +} + +html[dark] .ytpf-vote-btn:hover:not(:disabled) { + background: #3e3e3e; + color: #f1f1f1; +} + +.ytpf-vote-btn:disabled { + opacity: 0.3; + cursor: default; +} + +.ytpf-vote-count { + min-width: 16px; + text-align: center; + font-size: 12px; +} + +.ytpf-vote-count--voted { + font-weight: 700; +} + +.ytpf-vote-btn--active { + color: var(--yt-spec-call-to-action, #065fd4); +} + +html[dark] .ytpf-vote-btn--active { + color: #3ea6ff; +} + +.ytpf-vote-btn--dim { + opacity: 0.25; +} .ytpf-fetch-views-btn { padding: 4px 12px; diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts index 5d187f7..c091e8f 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -287,7 +287,14 @@ function compareFn(key: SortKey, dir: SortDir) { }; } -function buildCell(video: PlaylistVideo, col: Column, playlistId: string): HTMLTableCellElement { +type VoteHandler = (video: PlaylistVideo, action: "up" | "down") => void; + +function buildCell( + video: PlaylistVideo, + col: Column, + playlistId: string, + onVote?: VoteHandler, +): HTMLTableCellElement { const td = document.createElement("td"); td.className = `ytpf-td ${col.cls}`; @@ -341,7 +348,44 @@ function buildCell(video: PlaylistVideo, col: Column, playlistId: string): HTMLT td.textContent = video.addedBy ?? "--"; break; case "votes": - td.textContent = video.voteCount != null ? String(video.voteCount) : "--"; + if (video.setVideoId && onVote) { + const wrap = document.createElement("span"); + wrap.className = "ytpf-vote"; + + const vs = video.voteStatus; + + const upBtn = document.createElement("button"); + upBtn.className = "ytpf-vote-btn"; + if (vs === "up") upBtn.classList.add("ytpf-vote-btn--active"); + else if (vs === "down") upBtn.classList.add("ytpf-vote-btn--dim"); + upBtn.textContent = "\u25B2"; + upBtn.title = "Vote up"; + upBtn.addEventListener("click", (e) => { + e.stopPropagation(); + onVote(video, "up"); + }); + + const countSpan = document.createElement("span"); + countSpan.className = "ytpf-vote-count"; + if (vs) countSpan.classList.add("ytpf-vote-count--voted"); + countSpan.textContent = video.voteCount != null ? String(video.voteCount) : "0"; + + const downBtn = document.createElement("button"); + downBtn.className = "ytpf-vote-btn"; + if (vs === "down") downBtn.classList.add("ytpf-vote-btn--active"); + else if (vs === "up") downBtn.classList.add("ytpf-vote-btn--dim"); + downBtn.textContent = "\u25BC"; + downBtn.title = "Vote down"; + downBtn.addEventListener("click", (e) => { + e.stopPropagation(); + onVote(video, "down"); + }); + + wrap.append(upBtn, countSpan, downBtn); + td.appendChild(wrap); + } else { + td.textContent = video.voteCount != null ? String(video.voteCount) : "--"; + } break; } @@ -352,13 +396,14 @@ function buildRow( video: PlaylistVideo, playlistId: string, visibleColumns: Column[], + onVote?: VoteHandler, ): 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)); + tr.appendChild(buildCell(video, col, playlistId, onVote)); } return tr; @@ -369,6 +414,7 @@ export interface PlaylistTableHandle { appendVideos(newVideos: PlaylistVideo[]): void; setComplete(extractedCount: number): void; updateDetails(updates: DetailUpdate[]): void; + updateVoteStatuses(statuses: { setVideoId: string; voteStatus: "up" | "down" | null }[]): void; } export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs): PlaylistTableHandle { @@ -870,11 +916,81 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs }); } + // Vote handler for collaborative playlists + const pendingVotes = new Map(); + + const voteHandler: VoteHandler | undefined = isCollab + ? (video, action) => { + if (!video.setVideoId || pendingVotes.has(video.setVideoId)) return; + + // Save previous state for revert + const prevCount = video.voteCount ?? 0; + const prevStatus = video.voteStatus; + pendingVotes.set(video.setVideoId, { prevCount, prevStatus }); + + // Optimistic update + if (action === "up") { + if (video.voteStatus === "up") { + video.voteCount = prevCount - 1; + video.voteStatus = null; + } else { + video.voteCount = prevCount + 1; + video.voteStatus = "up"; + } + } else { + if (video.voteStatus === "down") { + video.voteCount = prevCount + 1; + video.voteStatus = null; + } else { + video.voteCount = prevCount - 1; + video.voteStatus = "down"; + } + } + renderRows(); + + // Dispatch to page script + document.dispatchEvent( + new CustomEvent("__yt_playlist_ext_vote", { + detail: JSON.stringify({ + setVideoId: video.setVideoId, + action, + }), + }), + ); + } + : undefined; + + // Listen for vote results from page script + if (isCollab) { + document.addEventListener("__yt_playlist_ext_vote_result", ((e: Event) => { + const { setVideoId, success, newVoteStatus } = JSON.parse((e as CustomEvent).detail); + const prev = pendingVotes.get(setVideoId); + pendingVotes.delete(setVideoId); + + if (!success && prev) { + // Revert optimistic update + const video = videos.find((v) => v.setVideoId === setVideoId); + if (video) { + video.voteCount = prev.prevCount; + video.voteStatus = prev.prevStatus; + renderRows(); + } + } else if (success) { + // Confirm new status + const video = videos.find((v) => v.setVideoId === setVideoId); + if (video) { + video.voteStatus = (newVoteStatus as "up" | "down" | null) ?? null; + renderRows(); + } + } + }) as EventListener); + } + function renderRows() { const filtered = getFilteredVideos(); tbody.textContent = ""; for (const video of filtered) { - tbody.appendChild(buildRow(video, playlistId, visibleColumns)); + tbody.appendChild(buildRow(video, playlistId, visibleColumns, voteHandler)); } filterCount.textContent = filtered.length < videos.length ? `${filtered.length} / ${videos.length}` @@ -1019,5 +1135,18 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs updateStats(); } - return { element: wrapper, appendVideos, setComplete, updateDetails }; + function updateVoteStatuses(statuses: { setVideoId: string; voteStatus: "up" | "down" | null }[]) { + const map = new Map(statuses.map((s) => [s.setVideoId, s.voteStatus])); + let changed = false; + for (const v of videos) { + const status = map.get(v.setVideoId ?? ""); + if (status !== undefined && v.voteStatus !== status) { + v.voteStatus = status; + changed = true; + } + } + if (changed) renderRows(); + } + + return { element: wrapper, appendVideos, setComplete, updateDetails, updateVoteStatuses }; } diff --git a/src/injected/page-script.ts b/src/injected/page-script.ts index fb6c44c..f6ad716 100644 --- a/src/injected/page-script.ts +++ b/src/injected/page-script.ts @@ -20,6 +20,54 @@ let collabCache: Map = new Map(); let collabCachePlaylistId: string | null = null; + // Cache vote feedback tokens per setVideoId + interface VoteFeedback { + upToken: string | null; + upToggledToken: string | null; + downToken: string | null; + downToggledToken: string | null; + isUpToggled: boolean; + isDownToggled: boolean; + } + const voteFeedbackCache = new Map(); + + function extractTokenFromButtonVM(model: any): string | null { + return model?.buttonViewModel?.onTap?.innertubeCommand + ?.feedbackEndpoint?.feedbackToken ?? null; + } + + /** Extract vote feedback tokens from playlistVideoRenderer.engagementBar */ + function extractVoteInfo(renderer: any): "up" | "down" | null { + const setVideoId = renderer.setVideoId; + if (!setVideoId) return null; + + const actions = renderer.engagementBar?.engagementBarViewModel?.actions; + if (!actions) return null; + + for (const action of actions) { + const voting = action.votingViewModel; + if (!voting) continue; + + const up = voting.upvoteButton?.toggleButtonViewModel; + const down = voting.downvoteButton?.toggleButtonViewModel; + + const info: VoteFeedback = { + upToken: extractTokenFromButtonVM(up?.defaultButtonViewModel), + upToggledToken: extractTokenFromButtonVM(up?.toggledButtonViewModel), + downToken: extractTokenFromButtonVM(down?.defaultButtonViewModel), + downToggledToken: extractTokenFromButtonVM(down?.toggledButtonViewModel), + isUpToggled: !!up?.isToggled, + isDownToggled: !!down?.isToggled, + }; + + if (info.upToken || info.downToken) { + voteFeedbackCache.set(setVideoId, info); + return info.isUpToggled ? "up" : info.isDownToggled ? "down" : null; + } + } + return null; + } + function runExtraction() { if (!isPlaylistUrl()) return; const url = window.location.href; @@ -128,6 +176,7 @@ function mapRenderers(renderers: any[], startIndex: number) { return renderers.map((d, i) => { const { viewCountText } = parseVideoInfo(d.videoInfo); + const voteStatus = extractVoteInfo(d); return { videoId: d.videoId, title: textOf(d.title), @@ -143,6 +192,8 @@ category: null as string | null, voteCount: typeof d.voteCount === "number" ? d.voteCount : null, addedBy: resolveAddedBy(d, avatarToName), + setVideoId: d.setVideoId ?? null, + voteStatus, }; }); } @@ -219,6 +270,7 @@ break; } } + } // --- helpers --- @@ -528,4 +580,82 @@ }), ); } + + // Vote handler: content script dispatches this event to vote on collaborative playlist videos + document.addEventListener("__yt_playlist_ext_vote", async (event: Event) => { + const { setVideoId, action } = JSON.parse( + (event as CustomEvent).detail, + ); + const { cfg, baseContext } = getConfig(); + const authHeaders = buildAuthHeaders(cfg, baseContext); + + const cached = voteFeedbackCache.get(setVideoId); + if (!cached) { + console.warn(LOG, `No vote feedback tokens for ${setVideoId}`); + sendVoteResult(setVideoId, false, null); + return; + } + + // Pick the correct feedbackToken based on action and current toggle state + let feedbackToken: string | null = null; + if (action === "up") { + feedbackToken = cached.isUpToggled ? cached.upToggledToken : cached.upToken; + } else { + feedbackToken = cached.isDownToggled ? cached.downToggledToken : cached.downToken; + } + + if (!feedbackToken) { + console.warn(LOG, `No feedback token for ${action} on ${setVideoId}`); + sendVoteResult(setVideoId, false, null); + return; + } + + try { + const res = await fetch( + "https://www.youtube.com/youtubei/v1/feedback?prettyPrint=false", + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders, + }, + credentials: "same-origin", + body: JSON.stringify({ + context: baseContext, + feedbackTokens: [feedbackToken], + isFeedbackTokenUnencrypted: false, + }), + }, + ); + + const data = await res.json(); + const success = res.ok && Array.isArray(data?.feedbackResponses); + + if (success) { + // Update cached toggle state + if (action === "up") { + cached.isUpToggled = !cached.isUpToggled; + if (cached.isUpToggled) cached.isDownToggled = false; + } else { + cached.isDownToggled = !cached.isDownToggled; + if (cached.isDownToggled) cached.isUpToggled = false; + } + } + + const newStatus = cached.isUpToggled ? "up" : cached.isDownToggled ? "down" : null; + console.log(LOG, `Vote ${action} on ${setVideoId}: ${success ? "OK" : "FAILED"} → ${newStatus}`); + sendVoteResult(setVideoId, success, newStatus); + } catch (e) { + console.error(LOG, "Vote failed:", e); + sendVoteResult(setVideoId, false, null); + } + }); + + function sendVoteResult(setVideoId: string, success: boolean, newVoteStatus: string | null) { + document.dispatchEvent( + new CustomEvent("__yt_playlist_ext_vote_result", { + detail: JSON.stringify({ setVideoId, success, newVoteStatus }), + }), + ); + } })(); diff --git a/src/types/playlist.ts b/src/types/playlist.ts index 15b83ae..5b1e54f 100644 --- a/src/types/playlist.ts +++ b/src/types/playlist.ts @@ -31,6 +31,10 @@ export interface PlaylistVideo { addedBy: string | null; /** Vote count / approvals (collaborative playlists only) */ voteCount: number | null; + /** Playlist-specific video ID for operations (vote, reorder, etc.) */ + setVideoId: string | null; + /** Current user's vote status on collaborative playlists */ + voteStatus: "up" | "down" | null; /** Like count from Data API */ likeCount: number | null; }