Voteのサポート

This commit is contained in:
Keisuke Hirata 2026-04-09 17:14:32 +09:00
parent 980bc54f33
commit 3635b58fed
7 changed files with 342 additions and 7 deletions

View File

@ -181,6 +181,8 @@ function parseVideo(renderer: any): PlaylistVideo | null {
addedBy: null, addedBy: null,
voteCount: null, voteCount: null,
likeCount: null, likeCount: null,
setVideoId: null,
voteStatus: null,
}; };
} }

View File

@ -3,7 +3,7 @@ import { onPlaylistPageReady, getPlaylistId } from "./navigation";
import { parseInitialData, buildPlaylistData } from "./extractor"; import { parseInitialData, buildPlaylistData } from "./extractor";
import type { PlaylistVideo } from "../types/playlist"; import type { PlaylistVideo } from "../types/playlist";
import type { Message } from "../shared/messages"; 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]"; const LOG = "[yt-playlist-features]";
@ -38,6 +38,8 @@ function mapRawVideo(v: any): PlaylistVideo {
addedBy: v.addedBy ?? null, addedBy: v.addedBy ?? null,
voteCount: v.voteCount ?? null, voteCount: v.voteCount ?? null,
likeCount: v.likeCount ?? null, likeCount: v.likeCount ?? null,
setVideoId: v.setVideoId ?? null,
voteStatus: v.voteStatus ?? null,
} satisfies PlaylistVideo; } satisfies PlaylistVideo;
} }
@ -122,6 +124,7 @@ function handlePlaylistAppend(event: Event): void {
document.addEventListener("__yt_playlist_ext_data", handlePlaylistData); document.addEventListener("__yt_playlist_ext_data", handlePlaylistData);
document.addEventListener("__yt_playlist_ext_data", handlePlaylistAppend); document.addEventListener("__yt_playlist_ext_data", handlePlaylistAppend);
// Listen for detail updates from background service worker // Listen for detail updates from background service worker
browser.runtime.onMessage.addListener((message: unknown) => { browser.runtime.onMessage.addListener((message: unknown) => {
const msg = message as Message; const msg = message as Message;

View File

@ -80,6 +80,10 @@ export function updateTableDetails(updates: DetailUpdate[]): void {
currentHandle?.updateDetails(updates); currentHandle?.updateDetails(updates);
} }
export function updateVoteStatuses(statuses: { setVideoId: string; voteStatus: "up" | "down" | null }[]): void {
currentHandle?.updateVoteStatuses(statuses);
}
export function unmountTable(): void { export function unmountTable(): void {
if (pendingObserver) { if (pendingObserver) {
pendingObserver.disconnect(); pendingObserver.disconnect();

View File

@ -515,7 +515,70 @@ html[dark] .ytpf-tr:hover {
.ytpf-col-category { width: 120px; } .ytpf-col-category { width: 120px; }
.ytpf-col-likes { width: 90px; text-align: right; font-family: "Roboto Mono", monospace; } .ytpf-col-likes { width: 90px; text-align: right; font-family: "Roboto Mono", monospace; }
.ytpf-col-addedby { width: 140px; } .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 { .ytpf-fetch-views-btn {
padding: 4px 12px; padding: 4px 12px;

View File

@ -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"); const td = document.createElement("td");
td.className = `ytpf-td ${col.cls}`; td.className = `ytpf-td ${col.cls}`;
@ -341,7 +348,44 @@ function buildCell(video: PlaylistVideo, col: Column, playlistId: string): HTMLT
td.textContent = video.addedBy ?? "--"; td.textContent = video.addedBy ?? "--";
break; break;
case "votes": case "votes":
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) : "--"; td.textContent = video.voteCount != null ? String(video.voteCount) : "--";
}
break; break;
} }
@ -352,13 +396,14 @@ function buildRow(
video: PlaylistVideo, video: PlaylistVideo,
playlistId: string, playlistId: string,
visibleColumns: Column[], visibleColumns: Column[],
onVote?: VoteHandler,
): HTMLTableRowElement { ): HTMLTableRowElement {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "ytpf-tr"; tr.className = "ytpf-tr";
if (!video.isPlayable) tr.classList.add("ytpf-tr--unplayable"); if (!video.isPlayable) tr.classList.add("ytpf-tr--unplayable");
for (const col of visibleColumns) { for (const col of visibleColumns) {
tr.appendChild(buildCell(video, col, playlistId)); tr.appendChild(buildCell(video, col, playlistId, onVote));
} }
return tr; return tr;
@ -369,6 +414,7 @@ export interface PlaylistTableHandle {
appendVideos(newVideos: PlaylistVideo[]): void; appendVideos(newVideos: PlaylistVideo[]): void;
setComplete(extractedCount: number): void; setComplete(extractedCount: number): void;
updateDetails(updates: DetailUpdate[]): void; updateDetails(updates: DetailUpdate[]): void;
updateVoteStatuses(statuses: { setVideoId: string; voteStatus: "up" | "down" | null }[]): void;
} }
export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs): PlaylistTableHandle { 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<string, { prevCount: number; prevStatus: "up" | "down" | null }>();
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() { function renderRows() {
const filtered = getFilteredVideos(); const filtered = getFilteredVideos();
tbody.textContent = ""; tbody.textContent = "";
for (const video of filtered) { for (const video of filtered) {
tbody.appendChild(buildRow(video, playlistId, visibleColumns)); tbody.appendChild(buildRow(video, playlistId, visibleColumns, voteHandler));
} }
filterCount.textContent = filtered.length < videos.length filterCount.textContent = filtered.length < videos.length
? `${filtered.length} / ${videos.length}` ? `${filtered.length} / ${videos.length}`
@ -1019,5 +1135,18 @@ export function renderPlaylistTable(data: PlaylistData, columnPrefs: ColumnPrefs
updateStats(); 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 };
} }

View File

@ -20,6 +20,54 @@
let collabCache: Map<string, string> = new Map(); let collabCache: Map<string, string> = new Map();
let collabCachePlaylistId: string | null = null; 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<string, VoteFeedback>();
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() { function runExtraction() {
if (!isPlaylistUrl()) return; if (!isPlaylistUrl()) return;
const url = window.location.href; const url = window.location.href;
@ -128,6 +176,7 @@
function mapRenderers(renderers: any[], startIndex: number) { function mapRenderers(renderers: any[], startIndex: number) {
return renderers.map((d, i) => { return renderers.map((d, i) => {
const { viewCountText } = parseVideoInfo(d.videoInfo); const { viewCountText } = parseVideoInfo(d.videoInfo);
const voteStatus = extractVoteInfo(d);
return { return {
videoId: d.videoId, videoId: d.videoId,
title: textOf(d.title), title: textOf(d.title),
@ -143,6 +192,8 @@
category: null as string | null, category: null as string | null,
voteCount: typeof d.voteCount === "number" ? d.voteCount : null, voteCount: typeof d.voteCount === "number" ? d.voteCount : null,
addedBy: resolveAddedBy(d, avatarToName), addedBy: resolveAddedBy(d, avatarToName),
setVideoId: d.setVideoId ?? null,
voteStatus,
}; };
}); });
} }
@ -219,6 +270,7 @@
break; break;
} }
} }
} }
// --- helpers --- // --- 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 }),
}),
);
}
})(); })();

View File

@ -31,6 +31,10 @@ export interface PlaylistVideo {
addedBy: string | null; addedBy: string | null;
/** Vote count / approvals (collaborative playlists only) */ /** Vote count / approvals (collaborative playlists only) */
voteCount: number | null; 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 */ /** Like count from Data API */
likeCount: number | null; likeCount: number | null;
} }