Voteのサポート
This commit is contained in:
parent
980bc54f33
commit
3635b58fed
|
|
@ -181,6 +181,8 @@ function parseVideo(renderer: any): PlaylistVideo | null {
|
|||
addedBy: null,
|
||||
voteCount: null,
|
||||
likeCount: null,
|
||||
setVideoId: null,
|
||||
voteStatus: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,54 @@
|
|||
let collabCache: Map<string, string> = 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<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() {
|
||||
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 }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user