追加者とVote, フィルタの追加
This commit is contained in:
parent
503f5a6e44
commit
6f186dcf31
|
|
@ -70,6 +70,8 @@ function handlePlaylistData(event: Event): void {
|
|||
(b: any) =>
|
||||
b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW",
|
||||
) ?? false,
|
||||
addedBy: v.addedBy ?? null,
|
||||
voteCount: v.voteCount ?? null,
|
||||
} satisfies PlaylistVideo;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,67 @@ const CSS = `
|
|||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.ytpf-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--yt-spec-10-percent-layer);
|
||||
background: var(--yt-spec-base-background);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ytpf-filter-input,
|
||||
.ytpf-filter-select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--yt-spec-10-percent-layer);
|
||||
border-radius: 8px;
|
||||
background: var(--yt-spec-badge-chip-background);
|
||||
color: var(--yt-spec-text-primary);
|
||||
font-size: 13px;
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ytpf-filter-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.ytpf-filter-select {
|
||||
cursor: pointer;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.ytpf-filter-input:focus,
|
||||
.ytpf-filter-select:focus {
|
||||
border-color: var(--yt-spec-call-to-action);
|
||||
}
|
||||
|
||||
.ytpf-filter-input::placeholder {
|
||||
color: var(--yt-spec-text-secondary);
|
||||
}
|
||||
|
||||
.ytpf-filter-select option {
|
||||
background: var(--yt-spec-base-background, #fff);
|
||||
color: var(--yt-spec-text-primary, #0f0f0f);
|
||||
}
|
||||
|
||||
html[dark] .ytpf-filter-select option {
|
||||
background: #282828;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
.ytpf-filters--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ytpf-filter-count {
|
||||
font-size: 12px;
|
||||
color: var(--yt-spec-text-secondary);
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ytpf-body {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
|
@ -106,6 +167,8 @@ const CSS = `
|
|||
.ytpf-col-title { width: auto; }
|
||||
.ytpf-col-channel { width: 180px; }
|
||||
.ytpf-col-duration { width: 80px; font-family: "Roboto Mono", monospace; }
|
||||
.ytpf-col-addedby { width: 140px; }
|
||||
.ytpf-col-votes { width: 60px; text-align: center; font-family: "Roboto Mono", monospace; }
|
||||
.ytpf-tr--unplayable {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
|
||||
|
||||
type SortKey = "index" | "title" | "channel" | "duration";
|
||||
type SortKey = "index" | "title" | "channel" | "duration" | "addedBy" | "votes";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
const columns: { label: string; cls: string; key: SortKey }[] = [
|
||||
interface Column {
|
||||
label: string;
|
||||
cls: string;
|
||||
key: SortKey;
|
||||
collab?: boolean; // only shown for collaborative playlists
|
||||
}
|
||||
|
||||
const allColumns: Column[] = [
|
||||
{ label: "#", cls: "ytpf-col-index", key: "index" },
|
||||
{ label: "Title", cls: "ytpf-col-title", key: "title" },
|
||||
{ label: "Channel", cls: "ytpf-col-channel", key: "channel" },
|
||||
{ label: "Duration", cls: "ytpf-col-duration", key: "duration" },
|
||||
{ label: "Added by", cls: "ytpf-col-addedby", key: "addedBy", collab: true },
|
||||
{ label: "Votes", cls: "ytpf-col-votes", key: "votes", collab: true },
|
||||
];
|
||||
|
||||
function compareFn(key: SortKey, dir: SortDir) {
|
||||
|
|
@ -22,11 +31,19 @@ function compareFn(key: SortKey, dir: SortDir) {
|
|||
return a.channel.name.localeCompare(b.channel.name) * m;
|
||||
case "duration":
|
||||
return ((a.durationSeconds ?? -1) - (b.durationSeconds ?? -1)) * m;
|
||||
case "addedBy":
|
||||
return (a.addedBy ?? "").localeCompare(b.addedBy ?? "") * m;
|
||||
case "votes":
|
||||
return ((a.voteCount ?? 0) - (b.voteCount ?? 0)) * m;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildRow(video: PlaylistVideo, playlistId: string): HTMLTableRowElement {
|
||||
function buildRow(
|
||||
video: PlaylistVideo,
|
||||
playlistId: string,
|
||||
isCollab: boolean,
|
||||
): HTMLTableRowElement {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "ytpf-tr";
|
||||
if (!video.isPlayable) tr.classList.add("ytpf-tr--unplayable");
|
||||
|
|
@ -75,10 +92,29 @@ function buildRow(video: PlaylistVideo, playlistId: string): HTMLTableRowElement
|
|||
}
|
||||
tr.appendChild(tdDuration);
|
||||
|
||||
if (isCollab) {
|
||||
// Added by
|
||||
const tdAddedBy = document.createElement("td");
|
||||
tdAddedBy.className = "ytpf-td ytpf-col-addedby";
|
||||
tdAddedBy.textContent = video.addedBy ?? "--";
|
||||
tr.appendChild(tdAddedBy);
|
||||
|
||||
// Votes
|
||||
const tdVotes = document.createElement("td");
|
||||
tdVotes.className = "ytpf-td ytpf-col-votes";
|
||||
tdVotes.textContent = video.voteCount != null ? String(video.voteCount) : "--";
|
||||
tr.appendChild(tdVotes);
|
||||
}
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
||||
const isCollab = data.videos.some(
|
||||
(v) => v.addedBy != null || v.voteCount != null,
|
||||
);
|
||||
const columns = allColumns.filter((c) => !c.collab || isCollab);
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "ytpf-wrapper";
|
||||
|
||||
|
|
@ -103,6 +139,50 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
|||
header.append(titleSpan, metaSpan, toggle);
|
||||
wrapper.appendChild(header);
|
||||
|
||||
// Filter bar
|
||||
const filters = document.createElement("div");
|
||||
filters.className = "ytpf-filters";
|
||||
|
||||
const titleInput = document.createElement("input");
|
||||
titleInput.className = "ytpf-filter-input";
|
||||
titleInput.type = "text";
|
||||
titleInput.placeholder = "タイトル検索...";
|
||||
filters.appendChild(titleInput);
|
||||
|
||||
const channelInput = document.createElement("input");
|
||||
channelInput.className = "ytpf-filter-input";
|
||||
channelInput.type = "text";
|
||||
channelInput.placeholder = "チャンネル検索...";
|
||||
filters.appendChild(channelInput);
|
||||
|
||||
if (isCollab) {
|
||||
const addedBySelect = document.createElement("select");
|
||||
addedBySelect.className = "ytpf-filter-select";
|
||||
const allOption = document.createElement("option");
|
||||
allOption.value = "";
|
||||
allOption.textContent = "追加者: すべて";
|
||||
addedBySelect.appendChild(allOption);
|
||||
|
||||
const addedByNames = [...new Set(
|
||||
data.videos.map((v) => v.addedBy).filter((n): n is string => n != null),
|
||||
)].sort();
|
||||
for (const name of addedByNames) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = name;
|
||||
opt.textContent = name;
|
||||
addedBySelect.appendChild(opt);
|
||||
}
|
||||
filters.appendChild(addedBySelect);
|
||||
|
||||
addedBySelect.addEventListener("change", applyFilters);
|
||||
}
|
||||
|
||||
const filterCount = document.createElement("span");
|
||||
filterCount.className = "ytpf-filter-count";
|
||||
filters.appendChild(filterCount);
|
||||
|
||||
wrapper.appendChild(filters);
|
||||
|
||||
// Body (table container)
|
||||
const body = document.createElement("div");
|
||||
body.className = "ytpf-body";
|
||||
|
|
@ -131,13 +211,38 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
|||
const playlistId = data.metadata.playlistId;
|
||||
const videos = [...data.videos];
|
||||
|
||||
function getFilteredVideos(): PlaylistVideo[] {
|
||||
const titleQuery = titleInput.value.toLowerCase();
|
||||
const channelQuery = channelInput.value.toLowerCase();
|
||||
const addedBySelect = filters.querySelector<HTMLSelectElement>(".ytpf-filter-select");
|
||||
const addedByFilter = addedBySelect?.value ?? "";
|
||||
|
||||
return videos.filter((v) => {
|
||||
if (titleQuery && !v.title.toLowerCase().includes(titleQuery)) return false;
|
||||
if (channelQuery && !v.channel.name.toLowerCase().includes(channelQuery)) return false;
|
||||
if (addedByFilter && v.addedBy !== addedByFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const filtered = getFilteredVideos();
|
||||
tbody.textContent = "";
|
||||
for (const video of videos) {
|
||||
tbody.appendChild(buildRow(video, playlistId));
|
||||
for (const video of filtered) {
|
||||
tbody.appendChild(buildRow(video, playlistId, isCollab));
|
||||
}
|
||||
filterCount.textContent = filtered.length < videos.length
|
||||
? `${filtered.length} / ${videos.length}`
|
||||
: "";
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
renderRows();
|
||||
}
|
||||
|
||||
titleInput.addEventListener("input", applyFilters);
|
||||
channelInput.addEventListener("input", applyFilters);
|
||||
|
||||
renderRows();
|
||||
|
||||
// Sort state
|
||||
|
|
@ -181,6 +286,7 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
|||
// Toggle collapse
|
||||
header.addEventListener("click", () => {
|
||||
body.classList.toggle("ytpf-body--hidden");
|
||||
filters.classList.toggle("ytpf-filters--hidden");
|
||||
toggle.classList.toggle("ytpf-toggle--collapsed");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,23 +15,30 @@
|
|||
if (w.__yt_playlist_ext_injected) return;
|
||||
w.__yt_playlist_ext_injected = true;
|
||||
|
||||
// Run extraction for the current page if it's a playlist
|
||||
if (isPlaylistUrl()) {
|
||||
let extractingUrl: string | null = null;
|
||||
let collabCache: Map<string, string> = new Map();
|
||||
let collabCachePlaylistId: string | null = null;
|
||||
|
||||
function runExtraction() {
|
||||
if (!isPlaylistUrl()) return;
|
||||
const url = window.location.href;
|
||||
if (url === extractingUrl) return;
|
||||
extractingUrl = url;
|
||||
extractAndSend();
|
||||
}
|
||||
|
||||
// Run extraction for the current page if it's a playlist
|
||||
runExtraction();
|
||||
|
||||
// Listen for SPA navigation
|
||||
document.addEventListener("yt-navigate-finish", () => {
|
||||
if (isPlaylistUrl()) {
|
||||
extractAndSend();
|
||||
}
|
||||
});
|
||||
document.addEventListener("yt-navigate-finish", runExtraction);
|
||||
|
||||
// Listen for explicit trigger from content script
|
||||
document.addEventListener("__yt_playlist_ext_trigger", () => {
|
||||
if (isPlaylistUrl()) {
|
||||
extractAndSend();
|
||||
}
|
||||
document.addEventListener("__yt_playlist_ext_trigger", runExtraction);
|
||||
|
||||
// Reset on navigation away
|
||||
document.addEventListener("yt-navigate-start", () => {
|
||||
extractingUrl = null;
|
||||
});
|
||||
|
||||
function isPlaylistUrl(): boolean {
|
||||
|
|
@ -146,6 +153,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch collaborator names for collaborative playlists
|
||||
const avatarToName = await fetchCollaboratorMap(initialData, cfg, baseContext, authHeaders);
|
||||
|
||||
const videos = allRenderers.map((d, i) => ({
|
||||
videoId: d.videoId,
|
||||
title: textOf(d.title),
|
||||
|
|
@ -156,6 +166,8 @@
|
|||
shortBylineText: d.shortBylineText,
|
||||
isPlayable: d.isPlayable !== false,
|
||||
badges: d.badges ?? [],
|
||||
voteCount: typeof d.voteCount === "number" ? d.voteCount : null,
|
||||
addedBy: resolveAddedBy(d, avatarToName),
|
||||
}));
|
||||
|
||||
console.log(LOG, `Extraction: ${videos.length} videos (${page - 1} continuation pages)`);
|
||||
|
|
@ -170,6 +182,107 @@
|
|||
|
||||
// --- helpers ---
|
||||
|
||||
function normalizeAvatarUrl(url: string): string {
|
||||
// Strip size parameter to make URLs comparable (e.g. =s48 vs =s176)
|
||||
return url.replace(/=s\d+/, "=s0");
|
||||
}
|
||||
|
||||
function extractCollabParams(initialData: any): string | null {
|
||||
const vm = initialData?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel;
|
||||
const rows = vm?.metadata?.contentMetadataViewModel?.metadataRows;
|
||||
if (!rows) return null;
|
||||
for (const row of rows) {
|
||||
for (const part of row.metadataParts ?? []) {
|
||||
const cmd = part?.avatarStack?.avatarStackViewModel?.rendererContext
|
||||
?.commandContext?.onTap?.innertubeCommand;
|
||||
const params = cmd?.showEngagementPanelEndpoint?.globalConfiguration?.params;
|
||||
if (params) return params;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchCollaboratorMap(
|
||||
initialData: any,
|
||||
cfg: any,
|
||||
baseContext: any,
|
||||
authHeaders: Record<string, string>,
|
||||
): Promise<Map<string, string>> {
|
||||
const params = extractCollabParams(initialData);
|
||||
if (!params) return new Map<string, string>();
|
||||
|
||||
// Return cached map if same playlist
|
||||
const playlistId = new URL(window.location.href).searchParams.get("list");
|
||||
if (playlistId && playlistId === collabCachePlaylistId && collabCache.size > 0) {
|
||||
return collabCache;
|
||||
}
|
||||
|
||||
const map = new Map<string, string>();
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://www.youtube.com/youtubei/v1/get_panel?prettyPrint=false",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...authHeaders,
|
||||
"X-Origin": window.location.origin,
|
||||
"X-Youtube-Bootstrap-Logged-In": "true",
|
||||
"X-Youtube-Client-Name": String(cfg.INNERTUBE_CONTEXT_CLIENT_NAME ?? "1"),
|
||||
"X-Youtube-Client-Version": cfg.INNERTUBE_CLIENT_VERSION ?? "",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
context: baseContext,
|
||||
panelId: "PAplaylist_collaborate",
|
||||
params,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) return map;
|
||||
const data = await res.json();
|
||||
const collaborators =
|
||||
data?.content?.engagementPanelSectionListRenderer?.content
|
||||
?.playlistCollaborationViewModel?.playlistCollaborators ?? [];
|
||||
for (const c of collaborators) {
|
||||
const item = c.contentListItemViewModel;
|
||||
if (!item) continue;
|
||||
const name = item.title?.content ?? "";
|
||||
const avatarUrl =
|
||||
item.avatar?.avatarViewModel?.image?.sources?.[0]?.url;
|
||||
if (name && avatarUrl) {
|
||||
map.set(normalizeAvatarUrl(avatarUrl), name);
|
||||
}
|
||||
}
|
||||
console.log(LOG, `Fetched ${map.size} collaborators`);
|
||||
collabCache = map;
|
||||
collabCachePlaylistId = playlistId;
|
||||
} catch (e) {
|
||||
console.error(LOG, "Failed to fetch collaborators:", e);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function resolveAddedBy(renderer: any, avatarToName: Map<string, string>): string | null {
|
||||
if (avatarToName.size === 0) return null;
|
||||
const overlays = renderer.thumbnailOverlays ?? [];
|
||||
for (const o of overlays) {
|
||||
const avatars =
|
||||
o.thumbnailOverlayAvatarStackViewModel?.avatarStack
|
||||
?.avatarStackViewModel?.avatars;
|
||||
if (!avatars) continue;
|
||||
for (const a of avatars) {
|
||||
const url = a.avatarViewModel?.image?.sources?.[0]?.url;
|
||||
if (url) {
|
||||
const name = avatarToName.get(normalizeAvatarUrl(url));
|
||||
if (name) return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchBrowseData(
|
||||
browseId: string,
|
||||
baseContext: any,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export interface PlaylistVideo {
|
|||
};
|
||||
isPlayable: boolean;
|
||||
isLive: boolean;
|
||||
/** Name of the collaborator who added this video (collaborative playlists only) */
|
||||
addedBy: string | null;
|
||||
/** Vote count / approvals (collaborative playlists only) */
|
||||
voteCount: number | null;
|
||||
}
|
||||
|
||||
export interface PlaylistMetadata {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user