Compare commits
3
Commits
005fb72a48
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35bcdfba7d | ||
|
|
0e1869aacf | ||
|
|
2a94e1124c |
@@ -0,0 +1,42 @@
|
|||||||
|
# YT Playlist Features
|
||||||
|
|
||||||
|
YouTube のプレイリストページに、ソート・フィルター付きのデータテーブルを追加するブラウザ拡張機能です。
|
||||||
|
|
||||||
|
## 機能
|
||||||
|
|
||||||
|
- **プレイリスト全件抽出** — ページネーションを自動処理し、すべての動画を取得
|
||||||
|
- **インタラクティブなテーブル表示** — タイトル・チャンネル・再生時間などをカラムごとにソート
|
||||||
|
- **フィルター** — タイトルのテキスト検索、チャンネル・追加者のタグベースのマルチセレクト(オートコンプリート付き)
|
||||||
|
- **共同編集プレイリスト対応** — 追加者名・投票数の表示とフィルター
|
||||||
|
- **ライブ配信バッジ** — ライブ中の動画を視覚的に識別
|
||||||
|
- **ダークモード対応** — YouTube のテーマに追従
|
||||||
|
- **多言語対応** — 日本語 / 英語(YouTube の言語設定に自動追従)
|
||||||
|
|
||||||
|
## 対応ブラウザ
|
||||||
|
|
||||||
|
| ブラウザ | 対応状況 |
|
||||||
|
| ----------------- | --------------------- |
|
||||||
|
| Chrome / Chromium | Manifest V3 |
|
||||||
|
| Firefox | Manifest V3 (v133.0+) |
|
||||||
|
|
||||||
|
## インストール(開発版)
|
||||||
|
|
||||||
|
### Chrome
|
||||||
|
|
||||||
|
1. `chrome://extensions` を開く
|
||||||
|
2. 「デベロッパー モード」を有効にする
|
||||||
|
3. 「パッケージ化されていない拡張機能を読み込む」から `dist/chrome` を選択
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
|
||||||
|
1. `about:debugging#/runtime/this-firefox` を開く
|
||||||
|
2. 「一時的なアドオンを読み込む」から `dist/firefox/manifest.json` を選択
|
||||||
|
|
||||||
|
## 使い方
|
||||||
|
|
||||||
|
1. YouTube のプレイリストページ (`youtube.com/playlist?list=...`) にアクセス
|
||||||
|
2. プレイリストの上部にテーブルが自動で表示される
|
||||||
|
|
||||||
|
## ライセンス
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "YT Playlist Features",
|
"name": "YT Playlist Features",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Extract and work with YouTube playlist data",
|
"description": "Extract and work with YouTube playlist data",
|
||||||
"permissions": ["storage"],
|
"permissions": [],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background/service-worker.js"
|
"service_worker": "background/service-worker.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "YT Playlist Features",
|
"name": "YT Playlist Features",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Extract and work with YouTube playlist data",
|
"description": "Extract and work with YouTube playlist data",
|
||||||
"permissions": ["storage"],
|
"permissions": [],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["background/service-worker.js"]
|
"scripts": ["background/service-worker.js"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import type { Message } from "../shared/messages";
|
import type { Message } from "../shared/messages";
|
||||||
import { savePlaylist, getPlaylist } from "../shared/storage";
|
|
||||||
|
|
||||||
const LOG_PREFIX = "[yt-playlist-features:bg]";
|
const LOG_PREFIX = "[yt-playlist-features:bg]";
|
||||||
|
|
||||||
@@ -11,15 +10,8 @@ browser.runtime.onMessage.addListener(
|
|||||||
if (msg.type === "PLAYLIST_EXTRACTED") {
|
if (msg.type === "PLAYLIST_EXTRACTED") {
|
||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
`Saving playlist: "${msg.data.metadata.title}" (${msg.data.extractedCount} videos)`,
|
`Playlist: "${msg.data.metadata.title}" (${msg.data.extractedCount} videos)`,
|
||||||
);
|
);
|
||||||
await savePlaylist(msg.data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "GET_PLAYLIST") {
|
|
||||||
const data = await getPlaylist(msg.playlistId);
|
|
||||||
return { type: "PLAYLIST_RESPONSE", data } satisfies Message;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
type MessageKey =
|
||||||
|
| "colIndex"
|
||||||
|
| "colTitle"
|
||||||
|
| "colChannel"
|
||||||
|
| "colDuration"
|
||||||
|
| "colAddedBy"
|
||||||
|
| "colVotes"
|
||||||
|
| "filterTitle"
|
||||||
|
| "filterChannel"
|
||||||
|
| "filterAddedBy"
|
||||||
|
| "badgeLive"
|
||||||
|
| "headerVideos";
|
||||||
|
|
||||||
|
const messages: Record<string, Record<MessageKey, string>> = {
|
||||||
|
ja: {
|
||||||
|
colIndex: "#",
|
||||||
|
colTitle: "タイトル",
|
||||||
|
colChannel: "チャンネル",
|
||||||
|
colDuration: "長さ",
|
||||||
|
colAddedBy: "追加者",
|
||||||
|
colVotes: "投票",
|
||||||
|
filterTitle: "タイトル検索...",
|
||||||
|
filterChannel: "チャンネル...",
|
||||||
|
filterAddedBy: "追加者...",
|
||||||
|
badgeLive: "ライブ",
|
||||||
|
headerVideos: "本の動画",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
colIndex: "#",
|
||||||
|
colTitle: "Title",
|
||||||
|
colChannel: "Channel",
|
||||||
|
colDuration: "Duration",
|
||||||
|
colAddedBy: "Added by",
|
||||||
|
colVotes: "Votes",
|
||||||
|
filterTitle: "Search title...",
|
||||||
|
filterChannel: "Channel...",
|
||||||
|
filterAddedBy: "Added by...",
|
||||||
|
badgeLive: "LIVE",
|
||||||
|
headerVideos: "videos",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectLang(): string {
|
||||||
|
const htmlLang = document.documentElement.lang;
|
||||||
|
if (htmlLang?.startsWith("ja")) return "ja";
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key: MessageKey): string {
|
||||||
|
const lang = detectLang();
|
||||||
|
return (messages[lang] ?? messages.en)[key];
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
|
import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
|
||||||
type SortKey = "index" | "title" | "channel" | "duration" | "addedBy" | "votes";
|
type SortKey = "index" | "title" | "channel" | "duration" | "addedBy" | "votes";
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
@@ -10,14 +11,16 @@ interface Column {
|
|||||||
collab?: boolean; // only shown for collaborative playlists
|
collab?: boolean; // only shown for collaborative playlists
|
||||||
}
|
}
|
||||||
|
|
||||||
const allColumns: Column[] = [
|
function getAllColumns(): Column[] {
|
||||||
{ label: "#", cls: "ytpf-col-index", key: "index" },
|
return [
|
||||||
{ label: "Title", cls: "ytpf-col-title", key: "title" },
|
{ label: t("colIndex"), cls: "ytpf-col-index", key: "index" },
|
||||||
{ label: "Channel", cls: "ytpf-col-channel", key: "channel" },
|
{ label: t("colTitle"), cls: "ytpf-col-title", key: "title" },
|
||||||
{ label: "Duration", cls: "ytpf-col-duration", key: "duration" },
|
{ label: t("colChannel"), cls: "ytpf-col-channel", key: "channel" },
|
||||||
{ label: "Added by", cls: "ytpf-col-addedby", key: "addedBy", collab: true },
|
{ label: t("colDuration"), cls: "ytpf-col-duration", key: "duration" },
|
||||||
{ label: "Votes", cls: "ytpf-col-votes", key: "votes", collab: true },
|
{ label: t("colAddedBy"), cls: "ytpf-col-addedby", key: "addedBy", collab: true },
|
||||||
|
{ label: t("colVotes"), cls: "ytpf-col-votes", key: "votes", collab: true },
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
interface TagInput {
|
interface TagInput {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
@@ -251,7 +254,7 @@ function buildRow(
|
|||||||
if (video.isLive) {
|
if (video.isLive) {
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "ytpf-live";
|
badge.className = "ytpf-live";
|
||||||
badge.textContent = "LIVE";
|
badge.textContent = t("badgeLive");
|
||||||
tdDuration.appendChild(badge);
|
tdDuration.appendChild(badge);
|
||||||
} else {
|
} else {
|
||||||
tdDuration.textContent = video.durationText ?? "--";
|
tdDuration.textContent = video.durationText ?? "--";
|
||||||
@@ -279,7 +282,7 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
|||||||
const isCollab = data.videos.some(
|
const isCollab = data.videos.some(
|
||||||
(v) => v.addedBy != null || v.voteCount != null,
|
(v) => v.addedBy != null || v.voteCount != null,
|
||||||
);
|
);
|
||||||
const columns = allColumns.filter((c) => !c.collab || isCollab);
|
const columns = getAllColumns().filter((c) => !c.collab || isCollab);
|
||||||
|
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.className = "ytpf-wrapper";
|
wrapper.className = "ytpf-wrapper";
|
||||||
@@ -290,7 +293,7 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
|||||||
|
|
||||||
const titleSpan = document.createElement("span");
|
const titleSpan = document.createElement("span");
|
||||||
titleSpan.className = "ytpf-header-title";
|
titleSpan.className = "ytpf-header-title";
|
||||||
titleSpan.textContent = `${data.metadata.title} — ${data.extractedCount} videos`;
|
titleSpan.textContent = `${data.metadata.title} — ${data.extractedCount} ${t("headerVideos")}`;
|
||||||
|
|
||||||
const metaSpan = document.createElement("span");
|
const metaSpan = document.createElement("span");
|
||||||
metaSpan.className = "ytpf-header-meta";
|
metaSpan.className = "ytpf-header-meta";
|
||||||
@@ -313,12 +316,12 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
|||||||
titleInput.className = "ytpf-filter-input";
|
titleInput.className = "ytpf-filter-input";
|
||||||
titleInput.type = "text";
|
titleInput.type = "text";
|
||||||
titleInput.name = "ytpf-title-filter";
|
titleInput.name = "ytpf-title-filter";
|
||||||
titleInput.placeholder = "タイトル検索...";
|
titleInput.placeholder = t("filterTitle");
|
||||||
filters.appendChild(titleInput);
|
filters.appendChild(titleInput);
|
||||||
|
|
||||||
// Channel tag input
|
// Channel tag input
|
||||||
const channelNames = [...new Set(data.videos.map((v) => v.channel.name))].sort();
|
const channelNames = [...new Set(data.videos.map((v) => v.channel.name))].sort();
|
||||||
const channelTagInput = createTagInput("チャンネル...", channelNames, () => applyFilters());
|
const channelTagInput = createTagInput(t("filterChannel"), channelNames, () => applyFilters());
|
||||||
filters.appendChild(channelTagInput.container);
|
filters.appendChild(channelTagInput.container);
|
||||||
|
|
||||||
// Added-by tag input (collab only)
|
// Added-by tag input (collab only)
|
||||||
@@ -327,7 +330,7 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
|
|||||||
const addedByNames = [...new Set(
|
const addedByNames = [...new Set(
|
||||||
data.videos.map((v) => v.addedBy).filter((n): n is string => n != null),
|
data.videos.map((v) => v.addedBy).filter((n): n is string => n != null),
|
||||||
)].sort();
|
)].sort();
|
||||||
addedByTagInput = createTagInput("追加者...", addedByNames, () => applyFilters());
|
addedByTagInput = createTagInput(t("filterAddedBy"), addedByNames, () => applyFilters());
|
||||||
filters.appendChild(addedByTagInput.container);
|
filters.appendChild(addedByTagInput.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import type { PlaylistData } from "../types/playlist";
|
import type { PlaylistData } from "../types/playlist";
|
||||||
|
|
||||||
export type Message =
|
export type Message = { type: "PLAYLIST_EXTRACTED"; data: PlaylistData };
|
||||||
| { type: "PLAYLIST_EXTRACTED"; data: PlaylistData }
|
|
||||||
| { type: "GET_PLAYLIST"; playlistId: string }
|
|
||||||
| { type: "PLAYLIST_RESPONSE"; data: PlaylistData | null };
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import browser from "webextension-polyfill";
|
|
||||||
import type { PlaylistData } from "../types/playlist";
|
|
||||||
|
|
||||||
const STORAGE_PREFIX = "playlist:";
|
|
||||||
|
|
||||||
export async function savePlaylist(data: PlaylistData): Promise<void> {
|
|
||||||
await browser.storage.local.set({
|
|
||||||
[STORAGE_PREFIX + data.metadata.playlistId]: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPlaylist(
|
|
||||||
playlistId: string,
|
|
||||||
): Promise<PlaylistData | null> {
|
|
||||||
const key = STORAGE_PREFIX + playlistId;
|
|
||||||
const result = await browser.storage.local.get(key);
|
|
||||||
return (result[key] as PlaylistData) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllPlaylists(): Promise<PlaylistData[]> {
|
|
||||||
const all = await browser.storage.local.get(null);
|
|
||||||
return Object.entries(all)
|
|
||||||
.filter(([key]) => key.startsWith(STORAGE_PREFIX))
|
|
||||||
.map(([, value]) => value as PlaylistData);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user