mirror of
https://github.com/yusufipk/YouTubeOutlierBadge.git
synced 2026-09-11 10:56:14 +00:00
Add badge menu and library
Clicking a badge now opens a menu instead of the video: copy a YouTube-style card image (thumbnail + title) to the clipboard, copy the title and link as text, download the card image, or save the video into the extension's own library page. The library groups entries into projects, supports search, moving, two-step deletes, JSON export with a field-level lossless import merge, and re-scores stale entries on open. Badge clicks are swallowed at window capture so YouTube's own handlers (notably on Shorts cards) never see them, and a fixed-position ghost copy of the badge keeps it visible and clickable above the hover preview overlay. Score tier colors and labels now live in one place (OBParse) shared by the badge, the watch panel, and the library.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
web-ext-artifacts/
|
||||
.claude/
|
||||
node_modules/
|
||||
*.xpi
|
||||
*.zip
|
||||
|
||||
@@ -9,7 +9,10 @@ would drag a mean upward and make everything else on that channel look like it
|
||||
never outperformed. Shorts are scored against the channel's Shorts, regular
|
||||
videos against regular videos, and live streams enter no pool at all. The watch
|
||||
page also gets a panel with the score's details and the channel's recent view
|
||||
distribution.
|
||||
distribution. Clicking a badge opens a small menu to copy the thumbnail and
|
||||
title, download the thumbnail, or save the video into the extension's own
|
||||
library, where entries live in projects, can be exported and imported as JSON,
|
||||
and get their scores refreshed when the page opens.
|
||||
|
||||
No server, no API key, no account: the extension calls YouTube's own internal
|
||||
(InnerTube) endpoint from inside youtube.com, without session credentials. One
|
||||
@@ -20,6 +23,10 @@ are only processed once they scroll into view.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Installing
|
||||
|
||||
[Get it from addons.mozilla.org](https://addons.mozilla.org/en-US/firefox/addon/youtube-outlier-badge/).
|
||||
|
||||
@@ -8,6 +8,10 @@ bilerek seçildi: tek bir viral video ortalamayı yukarı çekip aynı kanalın
|
||||
videolarını "outlier değil" gösterirdi. Shorts kendi havuzunda, normal videolar
|
||||
kendi havuzunda puanlanır, canlı yayınlar hiçbir havuza girmez. Video sayfasında
|
||||
ayrıca skorun ayrıntısı ve kanalın son videolarının izlenme dağılımı görünür.
|
||||
Rozete tıklamak küçük bir menü açar: kapak ve başlığı panoya kopyala, kapağı
|
||||
indir ya da videoyu eklentinin kendi kütüphanesine kaydet; kütüphanede kayıtlar
|
||||
projelere ayrılır, JSON olarak dışa ve içe aktarılır, skorlar sayfa açılınca
|
||||
tazelenir.
|
||||
|
||||
Sunucu, API anahtarı, hesap yok: eklenti youtube.com içinden YouTube'un kendi
|
||||
dahili (InnerTube) ucuna oturumsuz istek atar. Kanal başına bir istek, sonucu
|
||||
@@ -18,6 +22,10 @@ işlenir.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Kurulum
|
||||
|
||||
[addons.mozilla.org'dan yükleyin](https://addons.mozilla.org/tr/firefox/addon/youtube-outlier-badge/).
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/* Kütüphane sayfası: ayar penceresiyle aynı koyu palet. */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
font: 13px/1.45 "Segoe UI", Roboto, sans-serif;
|
||||
color: #eee;
|
||||
background: #202124;
|
||||
}
|
||||
|
||||
/* --- Kenar çubuğu --- */
|
||||
|
||||
#sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 230px;
|
||||
flex-shrink: 0;
|
||||
padding: 16px 12px;
|
||||
border-right: 1px solid #3c4043;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#projects {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 9px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-row:hover { background: #2b2c2f; }
|
||||
.nav-row.active { background: #3c4043; }
|
||||
|
||||
.nav-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-count { color: #9aa0a6; font-size: 12px; }
|
||||
|
||||
.nav-act {
|
||||
display: none;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #9aa0a6;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-row:hover .nav-act { display: inline; }
|
||||
.nav-act:hover { color: #eee; }
|
||||
|
||||
.nav-edit {
|
||||
width: 100%;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
background: #303134;
|
||||
color: #eee;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #303134;
|
||||
color: #eee;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) { background: #3c4043; }
|
||||
button:disabled { color: #9aa0a6; cursor: default; }
|
||||
|
||||
#newProject, #refresh { text-align: left; }
|
||||
|
||||
.io { display: flex; gap: 6px; margin-top: auto; }
|
||||
.io button { flex: 1; }
|
||||
|
||||
#notice { color: #9aa0a6; font-size: 12px; min-height: 1em; }
|
||||
|
||||
/* --- Ana alan --- */
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 16px 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
#search {
|
||||
flex: 1;
|
||||
max-width: 340px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
background: #303134;
|
||||
color: #eee;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#count { color: #9aa0a6; }
|
||||
|
||||
#grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#empty { color: #9aa0a6; }
|
||||
|
||||
/* --- Video kartı --- */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #3c4043;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #26272b;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chip {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
}
|
||||
|
||||
/* Rozetle aynı ton skalası. */
|
||||
.ob-t0 { background: rgba(60, 60, 60, 0.82); color: #d0d0d0; }
|
||||
.ob-t1 { background: rgba(29, 92, 168, 0.9); }
|
||||
.ob-t2 { background: rgba(21, 128, 61, 0.92); }
|
||||
.ob-t3 { background: rgba(180, 83, 9, 0.94); }
|
||||
.ob-t4 { background: rgba(159, 18, 57, 0.95); }
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 9px 11px 11px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: #eee;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-title:hover { text-decoration: underline; }
|
||||
|
||||
.card-meta { color: #9aa0a6; font-size: 12px; }
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.card-move {
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
background: #303134;
|
||||
color: #eee;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-act {
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-del:hover { background: #5c2b29; border-color: #7a3a37; }
|
||||
|
||||
/* İki adımlı silmenin ikinci adımı: düğme kızarır, tık onaydır. */
|
||||
.card-act.armed,
|
||||
.card-act.armed:hover { background: #5c2b29; border-color: #a15551; color: #ffd8d5; }
|
||||
|
||||
.nav-act.armed { display: inline; color: #f28b82; font-weight: 600; }
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Outlier Badge</title>
|
||||
<link rel="icon" href="../icons/icon.svg">
|
||||
<link rel="stylesheet" href="library.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside id="sidebar">
|
||||
<h1 data-i18n="libTitle"></h1>
|
||||
<nav id="projects"></nav>
|
||||
<button id="newProject" type="button" data-i18n="libNewProject"></button>
|
||||
<button id="refresh" type="button" data-i18n="libRefresh"></button>
|
||||
<div class="io">
|
||||
<button id="export" type="button" data-i18n="libExport"></button>
|
||||
<button id="import" type="button" data-i18n="libImport"></button>
|
||||
<input id="importFile" type="file" accept=".json,application/json" hidden>
|
||||
</div>
|
||||
<div id="notice"></div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<input id="search" type="search">
|
||||
<span id="count"></span>
|
||||
</header>
|
||||
<div id="grid"></div>
|
||||
<p id="empty" hidden></p>
|
||||
</main>
|
||||
|
||||
<script src="../src/i18n.js"></script>
|
||||
<script src="../src/parse.js"></script>
|
||||
<script src="../src/innertube.js"></script>
|
||||
<script src="../src/store.js"></script>
|
||||
<script src="../src/score.js"></script>
|
||||
<script src="../src/actions.js"></script>
|
||||
<script src="library.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,539 @@
|
||||
/* Kütüphane sayfası: kaydedilen videolar, projeler, içe/dışa aktarma.
|
||||
*
|
||||
* Veri storage.local'daki tek "library" anahtarında durur ve sayfa
|
||||
* storage.onChanged'i dinler: açık bir YouTube sekmesi menüden video
|
||||
* eklediğinde burası kendini yeniler, elle tazelemek gerekmez.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var api = (typeof browser !== "undefined" ? browser : chrome);
|
||||
var T = OBI18n;
|
||||
var P = OBParse;
|
||||
|
||||
var lib = { projects: [], items: [] };
|
||||
var current = "all"; /* "all" | "inbox" | proje kimliği */
|
||||
var query = "";
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (cls) node.className = cls;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function projectId() {
|
||||
return "p" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
|
||||
function projectOf(id) {
|
||||
for (var i = 0; i < lib.projects.length; i++) {
|
||||
if (lib.projects[i].id === id) return lib.projects[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Projesi silinmiş ya da hiç atanmamış her video gelen kutusundadır. */
|
||||
function bucketOf(item) {
|
||||
return item.projectId && projectOf(item.projectId) ? item.projectId : "inbox";
|
||||
}
|
||||
|
||||
/* Her değişiklik depodaki güncel kopyaya uygulanır (oku-değiştir-yaz):
|
||||
* sayfanın bellekteki kopyasını toptan yazmak, yazma ile onChanged yankısı
|
||||
* arasındaki milisaniyelik pencerede bir YouTube sekmesinin az önce
|
||||
* eklediği videoyu ezebiliyordu. Kendi yankımız yine yutulmuyor: yankı
|
||||
* yazılanla aynı veridir, depo tek gerçek kaynak kalır. */
|
||||
function persist(mutate) {
|
||||
return OBStore.readLibrary().then(function (fresh) {
|
||||
mutate(fresh);
|
||||
return OBStore.writeLibrary(fresh).then(function () {
|
||||
lib = fresh;
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* confirm() penceresi yok: Firefox'un eklenti sekmelerinde dialoglar
|
||||
* güvenilmez davranabiliyor ve olay döngüsünü bloklamaları başka hatalar
|
||||
* doğuruyor. Silme iki adım: ilk tık düğmeyi "Silinsin?" yapar, 3 saniye
|
||||
* içinde ikinci tık siler. */
|
||||
function armedDelete(btn, onDelete) {
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
if (btn.dataset.armed) { onDelete(); return; }
|
||||
btn.dataset.armed = "1";
|
||||
var old = btn.textContent;
|
||||
btn.textContent = T.t("libConfirmDelete");
|
||||
btn.classList.add("armed");
|
||||
setTimeout(function () {
|
||||
delete btn.dataset.armed;
|
||||
btn.textContent = old;
|
||||
btn.classList.remove("armed");
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Kenar çubuğu ---------------------------------------------------- */
|
||||
|
||||
function counts() {
|
||||
var map = { all: lib.items.length, inbox: 0 };
|
||||
lib.items.forEach(function (item) {
|
||||
var b = bucketOf(item);
|
||||
map[b] = (map[b] || 0) + 1;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function navRow(id, name, count, fixed) {
|
||||
var row = el("div", "nav-row" + (current === id ? " active" : ""));
|
||||
row.appendChild(el("span", "nav-name", name));
|
||||
row.appendChild(el("span", "nav-count", String(count || 0)));
|
||||
row.addEventListener("click", function () {
|
||||
current = id;
|
||||
render();
|
||||
});
|
||||
if (!fixed) {
|
||||
var rename = el("button", "nav-act", "✎");
|
||||
rename.type = "button";
|
||||
rename.title = T.t("libRenameProject");
|
||||
rename.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
editName(row, id);
|
||||
});
|
||||
var del = el("button", "nav-act", "×");
|
||||
del.type = "button";
|
||||
del.title = T.t("libDeleteProject") + ". " + T.t("libDeleteProjectHint") + ".";
|
||||
armedDelete(del, function () {
|
||||
if (current === id) current = "inbox";
|
||||
persist(function (l) {
|
||||
l.projects = l.projects.filter(function (q) { return q.id !== id; });
|
||||
});
|
||||
});
|
||||
row.appendChild(rename);
|
||||
row.appendChild(del);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function editName(row, id) {
|
||||
var p = projectOf(id);
|
||||
if (!p) return;
|
||||
var input = el("input", "nav-edit");
|
||||
input.value = p.name;
|
||||
row.textContent = "";
|
||||
row.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
/* finished bayrağı şart: render girdiyi DOM'dan sökünce Chromium blur'u
|
||||
* senkron ateşler ve bayrak olmadan iptal edilen ad blur üzerinden yine
|
||||
* kaydedilirdi. */
|
||||
var finished = false;
|
||||
var done = function (save) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
var name = input.value.trim();
|
||||
if (!save || !name) { render(); return; }
|
||||
persist(function (l) {
|
||||
for (var i = 0; i < l.projects.length; i++) {
|
||||
if (l.projects[i].id === id) l.projects[i].name = name;
|
||||
}
|
||||
});
|
||||
};
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") done(true);
|
||||
if (e.key === "Escape") done(false);
|
||||
});
|
||||
input.addEventListener("blur", function () { done(true); });
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
var nav = $("projects");
|
||||
nav.textContent = "";
|
||||
var c = counts();
|
||||
nav.appendChild(navRow("all", T.t("libAll"), c.all, true));
|
||||
nav.appendChild(navRow("inbox", T.t("libInbox"), c.inbox, true));
|
||||
lib.projects.forEach(function (p) {
|
||||
nav.appendChild(navRow(p.id, p.name, c[p.id], false));
|
||||
});
|
||||
}
|
||||
|
||||
function addProject() {
|
||||
var nav = $("projects");
|
||||
var row = el("div", "nav-row");
|
||||
var input = el("input", "nav-edit");
|
||||
input.placeholder = T.t("libProjectName");
|
||||
row.appendChild(input);
|
||||
nav.appendChild(row);
|
||||
input.focus();
|
||||
var finished = false;
|
||||
var done = function (save) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
var name = input.value.trim();
|
||||
if (save && name) {
|
||||
var p = { id: projectId(), name: name, createdAt: Date.now() };
|
||||
current = p.id;
|
||||
persist(function (l) { l.projects.push(p); });
|
||||
} else {
|
||||
row.remove();
|
||||
}
|
||||
};
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") done(true);
|
||||
if (e.key === "Escape") done(false);
|
||||
});
|
||||
input.addEventListener("blur", function () { done(true); });
|
||||
}
|
||||
|
||||
/* --- Video kartları -------------------------------------------------- */
|
||||
|
||||
function actionButton(labelKey, action) {
|
||||
var btn = el("button", "card-act", T.t(labelKey));
|
||||
btn.type = "button";
|
||||
btn.addEventListener("click", function () {
|
||||
btn.disabled = true;
|
||||
var old = btn.textContent;
|
||||
action().then(function () {
|
||||
btn.textContent = T.t("libDone");
|
||||
setTimeout(function () { btn.textContent = old; btn.disabled = false; }, 1200);
|
||||
}, function () {
|
||||
btn.textContent = T.t("menuFailed");
|
||||
setTimeout(function () { btn.textContent = old; btn.disabled = false; }, 1500);
|
||||
});
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function moveSelect(item) {
|
||||
var select = el("select", "card-move");
|
||||
var opt = function (value, text) {
|
||||
var o = el("option", null, text);
|
||||
o.value = value;
|
||||
select.appendChild(o);
|
||||
};
|
||||
opt("inbox", T.t("libInbox"));
|
||||
lib.projects.forEach(function (p) { opt(p.id, p.name); });
|
||||
select.value = bucketOf(item);
|
||||
select.addEventListener("change", function () {
|
||||
var value = select.value === "inbox" ? null : select.value;
|
||||
persist(function (l) {
|
||||
for (var i = 0; i < l.items.length; i++) {
|
||||
if (l.items[i].videoId === item.videoId) l.items[i].projectId = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
function card(item) {
|
||||
var box = el("div", "card");
|
||||
|
||||
var thumb = el("a", "thumb");
|
||||
thumb.href = item.url || OBActions.watchUrl(item.videoId, item.isShort);
|
||||
thumb.target = "_blank";
|
||||
thumb.rel = "noopener";
|
||||
var img = el("img");
|
||||
img.src = OBActions.thumbnailUrl(item.videoId, "hqdefault");
|
||||
img.loading = "lazy";
|
||||
img.alt = "";
|
||||
thumb.appendChild(img);
|
||||
if (item.score != null) {
|
||||
thumb.appendChild(el("span", "chip " + P.scoreTone(item.score), P.scoreLabel(item.score)));
|
||||
}
|
||||
box.appendChild(thumb);
|
||||
|
||||
var body = el("div", "card-body");
|
||||
var title = el("a", "card-title", item.title || item.videoId);
|
||||
title.href = thumb.href;
|
||||
title.target = "_blank";
|
||||
title.rel = "noopener";
|
||||
title.title = item.title || "";
|
||||
body.appendChild(title);
|
||||
|
||||
var meta = [];
|
||||
if (item.channel) meta.push(item.channel);
|
||||
if (item.savedAt) {
|
||||
meta.push(new Date(item.savedAt).toLocaleDateString(T.lang() === "tr" ? "tr-TR" : "en-US"));
|
||||
}
|
||||
body.appendChild(el("div", "card-meta", meta.join(" · ")));
|
||||
|
||||
body.appendChild(moveSelect(item));
|
||||
|
||||
var row = el("div", "card-row");
|
||||
var copyImg = actionButton("libCopyImage", function () {
|
||||
return OBActions.copyImage(item.videoId, item.title, item.channel);
|
||||
});
|
||||
copyImg.title = T.t("menuCopyImage");
|
||||
row.appendChild(copyImg);
|
||||
var copyTxt = actionButton("libCopyText", function () {
|
||||
return OBActions.copyText(item.videoId, item.title, item.isShort);
|
||||
});
|
||||
copyTxt.title = T.t("menuCopyText");
|
||||
row.appendChild(copyTxt);
|
||||
row.appendChild(actionButton("libDownload", function () {
|
||||
return OBActions.download(item.videoId, item.title, item.channel);
|
||||
}));
|
||||
var del = el("button", "card-act card-del", T.t("libDelete"));
|
||||
del.type = "button";
|
||||
armedDelete(del, function () {
|
||||
persist(function (l) {
|
||||
l.items = l.items.filter(function (it) { return it.videoId !== item.videoId; });
|
||||
});
|
||||
});
|
||||
row.appendChild(del);
|
||||
body.appendChild(row);
|
||||
|
||||
box.appendChild(body);
|
||||
return box;
|
||||
}
|
||||
|
||||
function visibleItems() {
|
||||
var q = query.toLowerCase();
|
||||
return lib.items.filter(function (item) {
|
||||
if (current !== "all" && bucketOf(item) !== current) return false;
|
||||
if (!q) return true;
|
||||
return (item.title || "").toLowerCase().indexOf(q) >= 0
|
||||
|| (item.channel || "").toLowerCase().indexOf(q) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
var grid = $("grid");
|
||||
grid.textContent = "";
|
||||
var items = visibleItems();
|
||||
items.forEach(function (item) { grid.appendChild(card(item)); });
|
||||
$("count").textContent = T.t("libCount", items.length);
|
||||
$("empty").hidden = items.length > 0;
|
||||
$("empty").textContent = T.t("libEmpty");
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderSidebar();
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
/* --- Skor tazeleme --------------------------------------------------- */
|
||||
|
||||
/* Kaydedilen skor kaydedildiği anın görüntüsü. Sayfa açılınca önbellek
|
||||
* ömründen (ttlHours ayarı) eski olanlar yeniden puanlanır; düğme hepsini
|
||||
* zorla tazeler. Video başına bir videoDetails isteği gider, kanal
|
||||
* baseline'ları içerik betiğiyle paylaşılan önbellekten okunur (taze
|
||||
* değilse OBScore kendisi çeker, kanal istekleri 4'le sınırlı). Büyük bir
|
||||
* kütüphanede bu yine de çok istek demek; aynı anda en çok 4 video işlenir
|
||||
* ve ttl dolmadan otomatik tazeleme tekrarlanmaz. */
|
||||
var MAX_REFRESH_PARALLEL = 4;
|
||||
var refreshing = false;
|
||||
|
||||
function refreshItem(it) {
|
||||
return OBTube.videoDetails(it.videoId).then(function (d) {
|
||||
if (!d.channelId) throw new Error("kanal bulunamadı");
|
||||
return OBScore.baseline(d.channelId, !!it.isShort).then(function (blob) {
|
||||
var own = OBScore.lookupViews(it.videoId, blob);
|
||||
var views = d.views != null ? d.views : (own ? own.views : null);
|
||||
var r = OBScore.score(it.videoId, views, !!it.isShort, blob);
|
||||
var patch = { scoredAt: Date.now() };
|
||||
if (d.title) patch.title = d.title;
|
||||
if (views != null) patch.views = views;
|
||||
if (r.score != null) patch.score = r.score;
|
||||
return { videoId: it.videoId, patch: patch, changed: r.score != null };
|
||||
});
|
||||
}, function () {
|
||||
/* Video silinmiş ya da ulaşılamıyor: skor olduğu gibi kalır, zaman yine
|
||||
* damgalanır ki her açılışta aynı video için istek yağmasın. */
|
||||
return { videoId: it.videoId, patch: { scoredAt: Date.now() }, changed: false };
|
||||
});
|
||||
}
|
||||
|
||||
function refreshScores(force) {
|
||||
if (refreshing) return;
|
||||
OBStore.settings().then(function (s) {
|
||||
var ttlMs = (s.ttlHours || 24) * 3600 * 1000;
|
||||
var stale = lib.items.filter(function (it) {
|
||||
return force || !it.scoredAt || Date.now() - it.scoredAt > ttlMs;
|
||||
});
|
||||
if (!stale.length) return;
|
||||
refreshing = true;
|
||||
$("refresh").disabled = true;
|
||||
notice(T.t("libRefreshing", stale.length));
|
||||
|
||||
/* Güncellemeler videoId ile sonda uygulanır: tazeleme sürerken silme
|
||||
* veya taşıma yapılabilir, eski nesne referansına yazmak kaybolurdu. */
|
||||
var queueItems = stale.slice();
|
||||
var active = 0;
|
||||
var updates = [];
|
||||
|
||||
var finish = function () {
|
||||
var changed = 0;
|
||||
persist(function (l) {
|
||||
updates.forEach(function (u) {
|
||||
for (var i = 0; i < l.items.length; i++) {
|
||||
if (l.items[i].videoId === u.videoId) {
|
||||
Object.assign(l.items[i], u.patch);
|
||||
if (u.changed) changed++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}).then(function () {
|
||||
refreshing = false;
|
||||
$("refresh").disabled = false;
|
||||
notice(T.t("libRefreshed", changed));
|
||||
});
|
||||
};
|
||||
|
||||
var startOne = function (it) {
|
||||
active++;
|
||||
refreshItem(it).then(function (u) {
|
||||
updates.push(u);
|
||||
}).catch(function () {}).then(function () {
|
||||
active--;
|
||||
pump();
|
||||
});
|
||||
};
|
||||
|
||||
var pump = function () {
|
||||
while (active < MAX_REFRESH_PARALLEL && queueItems.length) {
|
||||
startOne(queueItems.shift());
|
||||
}
|
||||
if (!active && !queueItems.length) finish();
|
||||
};
|
||||
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
/* --- İçe / dışa aktarma ---------------------------------------------- */
|
||||
|
||||
function notice(text) {
|
||||
$("notice").textContent = text;
|
||||
setTimeout(function () {
|
||||
if ($("notice").textContent === text) $("notice").textContent = "";
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
function exportLibrary() {
|
||||
var payload = {
|
||||
app: "outlier-badge",
|
||||
v: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
projects: lib.projects,
|
||||
items: lib.items
|
||||
};
|
||||
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "outlier-badge-library.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
||||
}
|
||||
|
||||
/* Birleştirme alan bazında kayıpsız: yeni videolar eklenir, mevcut videoda
|
||||
* içe aktarılan alanlar boşları doldurur ama yereldeki proje ataması,
|
||||
* eski bir dışa aktarımda proje yoksa geri alınmaz; savedAt/scoredAt
|
||||
* ikisinden yenisi kalır. Böylece bayat bir yedeği içe almak videoları
|
||||
* gelen kutusuna geri fırlatmaz. */
|
||||
function importLibrary(text) {
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (err) {
|
||||
notice(T.t("libImportFailed", err.message));
|
||||
return;
|
||||
}
|
||||
var projects = Array.isArray(data.projects) ? data.projects : [];
|
||||
var items = Array.isArray(data.items) ? data.items : [];
|
||||
var addedProjects = 0, addedItems = 0;
|
||||
|
||||
persist(function (l) {
|
||||
projects.forEach(function (p) {
|
||||
if (!p || !p.id || !p.name) return;
|
||||
var mine = null;
|
||||
for (var i = 0; i < l.projects.length; i++) {
|
||||
if (l.projects[i].id === p.id) mine = l.projects[i];
|
||||
}
|
||||
if (mine) { mine.name = String(p.name); return; }
|
||||
l.projects.push({ id: p.id, name: String(p.name), createdAt: p.createdAt || Date.now() });
|
||||
addedProjects++;
|
||||
});
|
||||
|
||||
items.forEach(function (it) {
|
||||
if (!it || !it.videoId) return;
|
||||
var local = null;
|
||||
l.items = l.items.filter(function (mine) {
|
||||
if (mine.videoId === it.videoId) { local = mine; return false; }
|
||||
return true;
|
||||
});
|
||||
l.items.push({
|
||||
videoId: String(it.videoId),
|
||||
title: String(it.title || (local && local.title) || ""),
|
||||
channel: String(it.channel || (local && local.channel) || ""),
|
||||
url: String(it.url || (local && local.url) || ""),
|
||||
isShort: !!(it.isShort != null ? it.isShort : local && local.isShort),
|
||||
score: typeof it.score === "number" ? it.score : (local ? local.score : null),
|
||||
views: typeof it.views === "number" ? it.views : (local ? local.views : null),
|
||||
savedAt: Math.max(it.savedAt || 0, (local && local.savedAt) || 0) || Date.now(),
|
||||
scoredAt: Math.max(it.scoredAt || 0, (local && local.scoredAt) || 0) || null,
|
||||
projectId: it.projectId || (local ? local.projectId : null)
|
||||
});
|
||||
if (!local) addedItems++;
|
||||
});
|
||||
|
||||
l.items.sort(function (a, b) { return (b.savedAt || 0) - (a.savedAt || 0); });
|
||||
}).then(function () {
|
||||
notice(T.t("libImported", addedItems, addedProjects));
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Kurulum --------------------------------------------------------- */
|
||||
|
||||
function applyTexts() {
|
||||
document.title = "Outlier Badge · " + T.t("libTitle");
|
||||
var nodes = document.querySelectorAll("[data-i18n]");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
nodes[i].textContent = T.t(nodes[i].dataset.i18n);
|
||||
}
|
||||
$("search").placeholder = T.t("libSearch");
|
||||
}
|
||||
|
||||
function bind() {
|
||||
$("newProject").addEventListener("click", addProject);
|
||||
$("refresh").addEventListener("click", function () { refreshScores(true); });
|
||||
$("export").addEventListener("click", exportLibrary);
|
||||
$("import").addEventListener("click", function () { $("importFile").click(); });
|
||||
$("importFile").addEventListener("change", function () {
|
||||
var file = $("importFile").files[0];
|
||||
if (!file) return;
|
||||
file.text().then(importLibrary, function (err) {
|
||||
notice(T.t("libImportFailed", err.message));
|
||||
});
|
||||
$("importFile").value = "";
|
||||
});
|
||||
$("search").addEventListener("input", function () {
|
||||
query = $("search").value.trim();
|
||||
renderGrid();
|
||||
});
|
||||
api.storage.onChanged.addListener(function (changes, area) {
|
||||
if (area !== "local" || !changes.library) return;
|
||||
lib = changes.library.newValue || { projects: [], items: [] };
|
||||
if (!Array.isArray(lib.projects)) lib.projects = [];
|
||||
if (!Array.isArray(lib.items)) lib.items = [];
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
OBStore.settings().then(function (s) {
|
||||
T.init(s.language);
|
||||
applyTexts();
|
||||
bind();
|
||||
return OBStore.readLibrary();
|
||||
}).then(function (data) {
|
||||
lib = data;
|
||||
render();
|
||||
refreshScores(false);
|
||||
});
|
||||
})();
|
||||
+3
-1
@@ -13,7 +13,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": ["storage"],
|
||||
"permissions": ["storage", "clipboardWrite"],
|
||||
"host_permissions": ["*://www.youtube.com/*"],
|
||||
"content_scripts": [
|
||||
{
|
||||
@@ -24,6 +24,8 @@
|
||||
"src/innertube.js",
|
||||
"src/store.js",
|
||||
"src/score.js",
|
||||
"src/actions.js",
|
||||
"src/menu.js",
|
||||
"src/badge.js",
|
||||
"src/panel.js",
|
||||
"src/content.js"
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ import sys
|
||||
import zipfile
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
INCLUDE_DIRS = ["src", "popup", "icons", "_locales"]
|
||||
INCLUDE_DIRS = ["src", "popup", "library", "icons", "_locales"]
|
||||
INCLUDE_FILES = ["manifest.json", "LICENSE"] # GPL kopyası pakette de bulunmalı
|
||||
OUTPUT = "outlier-badge.zip"
|
||||
|
||||
@@ -31,7 +31,7 @@ def check_syntax():
|
||||
"""node varsa her JS dosyasını ayrıştırmayı dene: bozuk bir dosyayı
|
||||
AMO'ya yükleyip incelemede öğrenmek yerine burada görelim."""
|
||||
files = []
|
||||
for d in ["src", "popup"]:
|
||||
for d in ["src", "popup", "library"]:
|
||||
for root, _, names in os.walk(os.path.join(ROOT, d)):
|
||||
files += [os.path.join(root, n) for n in names if n.endswith(".js")]
|
||||
try:
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
<select id="language"></select>
|
||||
</label>
|
||||
|
||||
<button id="library" type="button" class="wide" data-i18n="popupLibrary"></button>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<button id="clear" type="button" class="wide" data-i18n="popupClear"></button>
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
var api = (typeof browser !== "undefined" ? browser : chrome);
|
||||
var T = OBI18n;
|
||||
/* Sadece youtube.com: kapaklar (i.ytimg.com) ACAO:* gönderdiği için host
|
||||
* izni istemez ve buraya eklemek, sadece youtube iznini vermiş mevcut
|
||||
* kullanıcılara güncellemede yersiz bir uyarı gösterirdi. */
|
||||
var ORIGINS = { origins: ["*://www.youtube.com/*"] };
|
||||
|
||||
var FIELDS = [
|
||||
@@ -108,6 +111,11 @@
|
||||
});
|
||||
});
|
||||
|
||||
$("library").addEventListener("click", function () {
|
||||
api.tabs.create({ url: api.runtime.getURL("library/library.html") });
|
||||
window.close();
|
||||
});
|
||||
|
||||
$("clear").addEventListener("click", function () {
|
||||
OBStore.clearBaselines().then(function () {
|
||||
$("stats").textContent = T.t("popupCleared");
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
+256
@@ -0,0 +1,256 @@
|
||||
/* Rozet menüsünün ve kütüphane sayfasının ortak eylemleri: kart görselini
|
||||
* panoya kopyalama, indirme, kütüphaneye kaydetme.
|
||||
*
|
||||
* Hem içerik betiğinde hem eklenti sayfasında çalışır; YouTube DOM'una ve
|
||||
* InnerTube'a bağımlı değildir. Kapak i.ytimg.com'dan düz CORS ile çekilir:
|
||||
* sunucu Access-Control-Allow-Origin: * gönderdiği için ayrı host izni
|
||||
* gerekmiyor. Google bu başlığı bir gün kaldırırsa görsel kopyalama ve
|
||||
* indirme "olmadı" hatasına düşer; metin kopyalama etkilenmez.
|
||||
*/
|
||||
var OBActions = (function () {
|
||||
"use strict";
|
||||
|
||||
/* maxres her videoda yok; olmayanında YouTube 404 yerine küçük gri bir yer
|
||||
* tutucu da dönebiliyor. Sırayla denenir, ilk gerçek görsel kazanır.
|
||||
* hqdefault her videoda vardır. */
|
||||
var SIZES = ["maxresdefault", "sddefault", "hqdefault"];
|
||||
var PLACEHOLDER_MAX_BYTES = 4096;
|
||||
|
||||
function thumbnailUrl(videoId, size) {
|
||||
return "https://i.ytimg.com/vi/" + videoId + "/" + (size || "hqdefault") + ".jpg";
|
||||
}
|
||||
|
||||
function watchUrl(videoId, isShort) {
|
||||
return isShort
|
||||
? "https://www.youtube.com/shorts/" + videoId
|
||||
: "https://www.youtube.com/watch?v=" + videoId;
|
||||
}
|
||||
|
||||
function fetchThumbnail(videoId) {
|
||||
var attempt = function (i) {
|
||||
if (i >= SIZES.length) return Promise.reject(new Error("kapak alınamadı"));
|
||||
var next = function () { return attempt(i + 1); };
|
||||
return fetch(thumbnailUrl(videoId, SIZES[i]), { credentials: "omit" }).then(function (resp) {
|
||||
if (!resp.ok) return next();
|
||||
return resp.blob().then(function (blob) {
|
||||
if (blob.size < PLACEHOLDER_MAX_BYTES && i + 1 < SIZES.length) return next();
|
||||
return blob;
|
||||
});
|
||||
}, next);
|
||||
};
|
||||
return attempt(0);
|
||||
}
|
||||
|
||||
/* --- Kart görseli ---------------------------------------------------- */
|
||||
|
||||
/* Kopyala ve indir düz kapağı değil, YouTube'un koyu arayüzündeki karta
|
||||
* benzeyen tek bir görsel üretir: üstte köşeleri yuvarlak kapak, altında
|
||||
* başlık ve kanal adı. hq/sd kapaklar 4:3 geldiği için üst-alt siyah
|
||||
* bantlar 16:9 merkez kırpımıyla atılıyor; maxres zaten 16:9. */
|
||||
var CARD = {
|
||||
width: 1200,
|
||||
pad: 48,
|
||||
radius: 24,
|
||||
gap: 36,
|
||||
titleFont: "600 46px 'Roboto', 'Segoe UI', Arial, sans-serif",
|
||||
titleLine: 60,
|
||||
titleMaxLines: 3,
|
||||
channelFont: "400 34px 'Roboto', 'Segoe UI', Arial, sans-serif",
|
||||
channelHeight: 40,
|
||||
channelGap: 16,
|
||||
bg: "#0f0f0f",
|
||||
fg: "#f1f1f1",
|
||||
dim: "#aaaaaa"
|
||||
};
|
||||
|
||||
function roundedPath(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/* Sığmayan tek parça sözcük (URL, etiket zinciri, boşluksuz CJK başlık)
|
||||
* harf harf bölünür; yoksa satır kanvasın sağ kenarından taşıp kırpılırdı. */
|
||||
function breakWord(ctx, word, maxWidth) {
|
||||
var parts = [];
|
||||
var cur = "";
|
||||
for (var i = 0; i < word.length; i++) {
|
||||
var probe = cur + word.charAt(i);
|
||||
if (cur && ctx.measureText(probe).width > maxWidth) {
|
||||
parts.push(cur);
|
||||
cur = word.charAt(i);
|
||||
} else {
|
||||
cur = probe;
|
||||
}
|
||||
}
|
||||
if (cur) parts.push(cur);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function wrapLines(ctx, text, maxWidth, maxLines) {
|
||||
var words = [];
|
||||
String(text).split(/\s+/).filter(Boolean).forEach(function (w) {
|
||||
if (ctx.measureText(w).width > maxWidth) {
|
||||
words.push.apply(words, breakWord(ctx, w, maxWidth));
|
||||
} else {
|
||||
words.push(w);
|
||||
}
|
||||
});
|
||||
var lines = [];
|
||||
var line = "";
|
||||
var cut = false;
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
var probe = line ? line + " " + words[i] : words[i];
|
||||
if (line && ctx.measureText(probe).width > maxWidth) {
|
||||
lines.push(line);
|
||||
line = words[i];
|
||||
if (lines.length === maxLines) { cut = true; line = ""; break; }
|
||||
} else {
|
||||
line = probe;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
if (cut && lines.length) {
|
||||
var last = lines[lines.length - 1];
|
||||
while (last && ctx.measureText(last + "…").width > maxWidth) {
|
||||
last = last.slice(0, -1);
|
||||
}
|
||||
lines[lines.length - 1] = last + "…";
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function compose(videoId, title, channel) {
|
||||
return fetchThumbnail(videoId).then(function (blob) {
|
||||
return createImageBitmap(blob);
|
||||
}).then(function (bmp) {
|
||||
var sw = bmp.width;
|
||||
var sh = Math.round(sw * 9 / 16);
|
||||
if (sh > bmp.height) { sh = bmp.height; sw = Math.round(sh * 16 / 9); }
|
||||
var sx = Math.round((bmp.width - sw) / 2);
|
||||
var sy = Math.round((bmp.height - sh) / 2);
|
||||
|
||||
var W = CARD.width;
|
||||
var pad = CARD.pad;
|
||||
var tw = W - pad * 2;
|
||||
var th = Math.round(tw * 9 / 16);
|
||||
|
||||
var measure = document.createElement("canvas").getContext("2d");
|
||||
measure.font = CARD.titleFont;
|
||||
var lines = wrapLines(measure, title || "", tw, CARD.titleMaxLines);
|
||||
|
||||
var textTop = pad + th + CARD.gap;
|
||||
var H = lines.length ? textTop + lines.length * CARD.titleLine : pad + th;
|
||||
if (channel) H += CARD.channelGap + CARD.channelHeight;
|
||||
H += pad;
|
||||
|
||||
var canvas = document.createElement("canvas");
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = CARD.bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
ctx.save();
|
||||
roundedPath(ctx, pad, pad, tw, th, CARD.radius);
|
||||
ctx.clip();
|
||||
ctx.drawImage(bmp, sx, sy, sw, sh, pad, pad, tw, th);
|
||||
ctx.restore();
|
||||
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = CARD.fg;
|
||||
ctx.font = CARD.titleFont;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
ctx.fillText(lines[i], pad, textTop + i * CARD.titleLine);
|
||||
}
|
||||
if (channel) {
|
||||
ctx.fillStyle = CARD.dim;
|
||||
ctx.font = CARD.channelFont;
|
||||
ctx.fillText(channel, pad, textTop + lines.length * CARD.titleLine + CARD.channelGap);
|
||||
}
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
canvas.toBlob(function (png) {
|
||||
if (png) resolve(png);
|
||||
else reject(new Error("png dönüşümü başarısız"));
|
||||
}, "image/png");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* Görsel ve metin ayrı kopyalanır. İkisini tek pano yazımında vermek
|
||||
* denendi; yapıştırılan alan biçimi kendisi seçiyor ve metin kutuları hep
|
||||
* düz metni aldığı için görselin gittiği yeri kullanıcı kestiremiyordu.
|
||||
* ClipboardItem'a doğrudan promise veriliyor: önce çizip sonra yazmak
|
||||
* kullanıcı jestinin süresini aşabiliyor. */
|
||||
function copyImage(videoId, title, channel) {
|
||||
if (typeof ClipboardItem === "undefined" || !navigator.clipboard.write) {
|
||||
return Promise.reject(new Error("pano görsel desteklemiyor"));
|
||||
}
|
||||
return navigator.clipboard.write([
|
||||
new ClipboardItem({ "image/png": compose(videoId, title, channel) })
|
||||
]);
|
||||
}
|
||||
|
||||
function copyText(videoId, title, isShort) {
|
||||
var text = (title ? title + "\n" : "") + watchUrl(videoId, isShort);
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
/* Dosya adı başlıktan gelir; yalnız dosya sisteminin yasakladığı karakterler
|
||||
* atılır, Türkçe harfler kalır. */
|
||||
function fileName(title, videoId) {
|
||||
var base = String(title || "")
|
||||
.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
return (base || videoId) + ".png";
|
||||
}
|
||||
|
||||
function download(videoId, title, channel) {
|
||||
return compose(videoId, title, channel).then(function (blob) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName(title, videoId);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
function save(entry) {
|
||||
return OBStore.libraryAdd({
|
||||
videoId: entry.videoId,
|
||||
title: entry.title || "",
|
||||
channel: entry.channel || "",
|
||||
url: watchUrl(entry.videoId, entry.isShort),
|
||||
isShort: !!entry.isShort,
|
||||
score: entry.score != null ? entry.score : null,
|
||||
views: entry.views != null ? entry.views : null,
|
||||
savedAt: Date.now(),
|
||||
/* Skorsuz kayıt (rozet daha yüklenirken menüden eklenen video) taze
|
||||
* damgalanırsa kütüphanenin otomatik tazelemesi onu ttl boyunca atlar;
|
||||
* damgasız kalsın ki ilk açılışta puanlansın. */
|
||||
scoredAt: entry.score != null ? Date.now() : null,
|
||||
projectId: entry.projectId != null ? entry.projectId : null
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
watchUrl: watchUrl,
|
||||
fetchThumbnail: fetchThumbnail,
|
||||
compose: compose,
|
||||
copyImage: copyImage,
|
||||
copyText: copyText,
|
||||
download: download,
|
||||
save: save
|
||||
};
|
||||
})();
|
||||
+76
-29
@@ -25,23 +25,6 @@ var OBBadge = (function () {
|
||||
return card;
|
||||
}
|
||||
|
||||
function isShortCard(card) {
|
||||
return /shorts-lockup/i.test(card.tagName) || !!card.querySelector('a[href*="/shorts/"]');
|
||||
}
|
||||
|
||||
function tone(score) {
|
||||
if (score >= 10) return "ob-t4";
|
||||
if (score >= 5) return "ob-t3";
|
||||
if (score >= 2) return "ob-t2";
|
||||
if (score >= 1) return "ob-t1";
|
||||
return "ob-t0";
|
||||
}
|
||||
|
||||
function label(score) {
|
||||
if (score >= 10) return Math.round(score) + "x";
|
||||
return T.num(Math.round(score * 10) / 10, 1) + "x";
|
||||
}
|
||||
|
||||
function element(card) {
|
||||
var box = host(card);
|
||||
var el = box.querySelector(":scope > .ob-badge");
|
||||
@@ -50,30 +33,29 @@ var OBBadge = (function () {
|
||||
el.className = "ob-badge";
|
||||
if (getComputedStyle(box).position === "static") box.style.position = "relative";
|
||||
box.appendChild(el);
|
||||
/* Tıklama dinleyicisi burada yok: rozet tıklamaları OBMenu window
|
||||
* seviyesinde, capture aşamasında yakalar. Rozetin kendi üstündeki bir
|
||||
* dinleyici Shorts kartlarında işe yaramıyordu, YouTube tıklamayı
|
||||
* belge seviyesindeki capture dinleyicisiyle daha önce alıp Short'u
|
||||
* açıyordu. */
|
||||
}
|
||||
/* Shorts kapağının altı izlenme metniyle dolu, rozet orada üste gider. */
|
||||
el.dataset.short = isShortCard(card) ? "1" : "";
|
||||
return el;
|
||||
}
|
||||
|
||||
function base(el) {
|
||||
return "ob-badge" + (el.dataset.short ? " ob-short" : "");
|
||||
}
|
||||
|
||||
function loading(card) {
|
||||
var el = element(card);
|
||||
el.className = base(el) + " ob-loading";
|
||||
el.className = "ob-badge ob-loading";
|
||||
el.textContent = "";
|
||||
el.removeAttribute("title");
|
||||
}
|
||||
|
||||
function show(card, result, extra) {
|
||||
var el = element(card);
|
||||
el.className = base(el) + " " + tone(result.score);
|
||||
el.textContent = label(result.score);
|
||||
el.className = "ob-badge " + P.scoreTone(result.score);
|
||||
el.textContent = P.scoreLabel(result.score);
|
||||
var unit = T.t(extra && extra.isShort ? "unitShorts" : "unitVideos");
|
||||
var lines = [
|
||||
T.t("badgeScore", label(result.score)),
|
||||
T.t("badgeScore", P.scoreLabel(result.score)),
|
||||
T.t("badgeViews", P.humanCount(result.views)),
|
||||
T.t("badgeMedian", P.humanCount(result.median), result.sample, unit)
|
||||
];
|
||||
@@ -90,7 +72,7 @@ var OBBadge = (function () {
|
||||
|
||||
function fail(card, reason) {
|
||||
var el = element(card);
|
||||
el.className = base(el) + " ob-none";
|
||||
el.className = "ob-badge ob-none";
|
||||
el.textContent = "-";
|
||||
el.title = reason || T.t("badgeFailed");
|
||||
}
|
||||
@@ -101,5 +83,70 @@ var OBBadge = (function () {
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
return { loading: loading, show: show, fail: fail, clear: clear };
|
||||
/* Rozet hâlâ yerinde mi? Hover önizlemesi kapak kutusunu yeniden kurup
|
||||
* rozeti silebiliyor; tarama bununla anlayıp geri basar. */
|
||||
function present(card) {
|
||||
return !!host(card).querySelector(":scope > .ob-badge");
|
||||
}
|
||||
|
||||
/* --- Hayalet rozet ----------------------------------------------------
|
||||
*
|
||||
* Hover önizlemesi (ytd-video-preview) kartın üstüne uygulama seviyesinde
|
||||
* ayrı bir katman bindiriyor; kartın içindeki rozet z-index'i ne olursa
|
||||
* olsun o katmanın altında kalıyor. Çare: fare karta girince rozetin
|
||||
* birebir kopyası body'ye fixed basılır. Body çocuğu olduğu ve z-index'i
|
||||
* en yüksek olduğu için önizlemenin de üstünde durur, tıklanabilir kalır.
|
||||
*
|
||||
* Hayaletin ömrü DOM'la değil geometriyle yönetilir: önizleme katmanı
|
||||
* kartın soyundan gelmediği için "fare hâlâ kartın içinde mi" sorusuna
|
||||
* closest() cevap veremez; onun yerine imlecin kartın dikdörtgeni içinde
|
||||
* olup olmadığına bakılır (önizleme karttan biraz taştığı için payla). */
|
||||
var GHOST_MARGIN = 40;
|
||||
var ghost = null;
|
||||
var ghostCard = null;
|
||||
|
||||
function hideGhost() {
|
||||
if (ghost) { ghost.remove(); ghost = null; ghostCard = null; }
|
||||
}
|
||||
|
||||
function showGhost(card) {
|
||||
var el = host(card).querySelector(":scope > .ob-badge");
|
||||
if (!el) { hideGhost(); return; }
|
||||
var rect = el.getBoundingClientRect();
|
||||
if (!rect.width) { hideGhost(); return; }
|
||||
if (!ghost) {
|
||||
ghost = document.createElement("div");
|
||||
ghost.dataset.obGhost = "1";
|
||||
document.body.appendChild(ghost);
|
||||
}
|
||||
ghost.className = el.className + " ob-ghost";
|
||||
ghost.textContent = el.textContent;
|
||||
ghost.title = el.title || "";
|
||||
ghost.obCard = card;
|
||||
ghostCard = card;
|
||||
ghost.style.left = rect.left + "px";
|
||||
ghost.style.top = rect.top + "px";
|
||||
}
|
||||
|
||||
document.addEventListener("mouseover", function (e) {
|
||||
var node = e.target;
|
||||
if (node && node.dataset && node.dataset.obGhost) return;
|
||||
while (node && !node.obVideoId) node = node.parentElement;
|
||||
if (node && (node !== ghostCard || !ghost)) showGhost(node);
|
||||
}, true);
|
||||
|
||||
document.addEventListener("mousemove", function (e) {
|
||||
if (!ghost) return;
|
||||
if (!ghostCard || !ghostCard.isConnected) { hideGhost(); return; }
|
||||
var r = ghostCard.getBoundingClientRect();
|
||||
if (e.clientX < r.left - GHOST_MARGIN || e.clientX > r.right + GHOST_MARGIN ||
|
||||
e.clientY < r.top - GHOST_MARGIN || e.clientY > r.bottom + GHOST_MARGIN) {
|
||||
hideGhost();
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.addEventListener("scroll", hideGhost, true);
|
||||
window.addEventListener("yt-navigate-start", hideGhost);
|
||||
|
||||
return { loading: loading, show: show, fail: fail, clear: clear, present: present };
|
||||
})();
|
||||
|
||||
+26
-2
@@ -146,12 +146,21 @@
|
||||
|
||||
function processCard(card) {
|
||||
var videoId = videoIdOf(card);
|
||||
if (!videoId) return;
|
||||
/* Erken dönüşlerin hepsi obBadged'i düşürmek zorunda: bayrak yüksek ama
|
||||
* rozet yok kalırsa scan()'in geri basma dalı kartı her turda yeniden
|
||||
* işleyen bir döngüye girer. */
|
||||
if (!videoId) { card.obBadged = false; return; }
|
||||
if (card.obVideoId === videoId) return;
|
||||
card.obVideoId = videoId;
|
||||
card.obScore = null;
|
||||
card.obViews = null;
|
||||
|
||||
var isShort = isShortCard(card);
|
||||
if (isShort && !settings.scoreShorts) { OBBadge.clear(card); return; }
|
||||
if (isShort && !settings.scoreShorts) {
|
||||
card.obBadged = false;
|
||||
OBBadge.clear(card);
|
||||
return;
|
||||
}
|
||||
|
||||
counters.seen++;
|
||||
OBBadge.loading(card);
|
||||
@@ -166,19 +175,26 @@
|
||||
var result = OBScore.score(videoId, views, isShort, blob);
|
||||
if (result.score == null) {
|
||||
counters.skipped++;
|
||||
card.obBadged = false;
|
||||
OBBadge.clear(card);
|
||||
return;
|
||||
}
|
||||
if (settings.minScore && result.score < settings.minScore) {
|
||||
card.obBadged = false;
|
||||
OBBadge.clear(card);
|
||||
return;
|
||||
}
|
||||
var age = own && own.age != null ? own.age : domAgeDays(card);
|
||||
/* Rozet menüsü kaydederken skoru da yazsın diye kartta saklanır. */
|
||||
card.obScore = result.score;
|
||||
card.obViews = result.views;
|
||||
counters.scored++;
|
||||
card.obBadged = true;
|
||||
OBBadge.show(card, result, { young: age != null && age < 7, isShort: isShort });
|
||||
}).catch(function (err) {
|
||||
counters.failed++;
|
||||
lastError = String(err && err.message ? err.message : err);
|
||||
card.obBadged = false;
|
||||
OBBadge.clear(card);
|
||||
});
|
||||
}
|
||||
@@ -261,6 +277,13 @@
|
||||
/* YouTube kartları geri dönüştürüyor: aynı DOM elemanı başka videoya
|
||||
* bağlıysa rozet eskimiş demektir. */
|
||||
processCard(card);
|
||||
} else if (card.obBadged && !OBBadge.present(card)) {
|
||||
/* Hover önizlemesi (özellikle Shorts'ta) kapak kutusunu yeniden
|
||||
* kuruyor ve rozet DOM'dan siliniyor. Baseline önbellekte, geri
|
||||
* basmak ucuz. obVideoId sıfırlanır ki processCard "zaten işledim"
|
||||
* deyip erken dönmesin. */
|
||||
card.obVideoId = null;
|
||||
processCard(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,6 +299,7 @@
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
OBBadge.clear(cards[i]);
|
||||
cards[i].obVideoId = null;
|
||||
cards[i].obBadged = false;
|
||||
}
|
||||
OBPanel.remove();
|
||||
panelVideoId = null;
|
||||
|
||||
+74
@@ -21,6 +21,42 @@ var OBI18n = (function () {
|
||||
unitVideos: "video",
|
||||
unitShorts: "Shorts",
|
||||
|
||||
menuCopyImage: "Görseli kopyala",
|
||||
menuCopyText: "Başlık + linki kopyala",
|
||||
menuCopied: "Kopyalandı ✓",
|
||||
menuDownload: "Kapak + başlığı indir",
|
||||
menuDownloaded: "İndirildi ✓",
|
||||
menuSave: "Kütüphaneye ekle",
|
||||
menuSaved: "Eklendi ✓",
|
||||
menuUnsave: "Kütüphaneden çıkar",
|
||||
menuRemoved: "Çıkarıldı ✓",
|
||||
menuFailed: "Olmadı, tekrar dene",
|
||||
|
||||
libTitle: "Kütüphane",
|
||||
libAll: "Tümü",
|
||||
libInbox: "Gelen kutusu",
|
||||
libNewProject: "+ Yeni proje",
|
||||
libProjectName: "Proje adı",
|
||||
libRenameProject: "Yeniden adlandır",
|
||||
libDeleteProject: "Projeyi sil",
|
||||
libDeleteProjectHint: "Silinen projenin videoları gelen kutusuna taşınır",
|
||||
libSearch: "Başlık veya kanal ara",
|
||||
libRefresh: "Skorları güncelle",
|
||||
libRefreshing: "Skorlar güncelleniyor ($1 video)…",
|
||||
libRefreshed: "$1 videonun skoru güncellendi.",
|
||||
libExport: "Dışa aktar",
|
||||
libImport: "İçe aktar",
|
||||
libImported: "$1 video, $2 proje içe aktarıldı.",
|
||||
libImportFailed: "Dosya okunamadı: $1",
|
||||
libEmpty: "Burada henüz video yok. YouTube'da bir rozete tıklayıp \"Kütüphaneye ekle\" de.",
|
||||
libCount: "$1 video",
|
||||
libCopyImage: "Görsel",
|
||||
libCopyText: "Metin",
|
||||
libDownload: "İndir",
|
||||
libDelete: "Sil",
|
||||
libConfirmDelete: "Silinsin?",
|
||||
libDone: "✓",
|
||||
|
||||
panelLoading: "Outlier hesaplanıyor",
|
||||
panelTitle: "Outlier",
|
||||
panelHead: "kanal medyanının katı",
|
||||
@@ -52,6 +88,7 @@ var OBI18n = (function () {
|
||||
popupMinScore: "Şu skorun altını gizle",
|
||||
popupTtl: "Önbellek ömrü",
|
||||
popupLanguage: "Dil",
|
||||
popupLibrary: "Kütüphaneyi aç",
|
||||
popupClear: "Önbelleği temizle",
|
||||
popupCleared: "Önbellek temizlendi.",
|
||||
popupNote: "Skor = videonun izlenmesi / kanalın son N videosunun izlenme medyanı. " +
|
||||
@@ -84,6 +121,42 @@ var OBI18n = (function () {
|
||||
unitVideos: "videos",
|
||||
unitShorts: "Shorts",
|
||||
|
||||
menuCopyImage: "Copy image",
|
||||
menuCopyText: "Copy title + link",
|
||||
menuCopied: "Copied ✓",
|
||||
menuDownload: "Download thumbnail + title",
|
||||
menuDownloaded: "Downloaded ✓",
|
||||
menuSave: "Add to library",
|
||||
menuSaved: "Added ✓",
|
||||
menuUnsave: "Remove from library",
|
||||
menuRemoved: "Removed ✓",
|
||||
menuFailed: "Failed, try again",
|
||||
|
||||
libTitle: "Library",
|
||||
libAll: "All",
|
||||
libInbox: "Inbox",
|
||||
libNewProject: "+ New project",
|
||||
libProjectName: "Project name",
|
||||
libRenameProject: "Rename",
|
||||
libDeleteProject: "Delete project",
|
||||
libDeleteProjectHint: "A deleted project's videos move to the inbox",
|
||||
libSearch: "Search title or channel",
|
||||
libRefresh: "Refresh scores",
|
||||
libRefreshing: "Updating scores ($1 videos)…",
|
||||
libRefreshed: "Updated scores for $1 videos.",
|
||||
libExport: "Export",
|
||||
libImport: "Import",
|
||||
libImported: "Imported $1 videos and $2 projects.",
|
||||
libImportFailed: "Could not read the file: $1",
|
||||
libEmpty: "Nothing here yet. Click a badge on YouTube and pick \"Add to library\".",
|
||||
libCount: "$1 videos",
|
||||
libCopyImage: "Image",
|
||||
libCopyText: "Text",
|
||||
libDownload: "Download",
|
||||
libDelete: "Delete",
|
||||
libConfirmDelete: "Delete?",
|
||||
libDone: "✓",
|
||||
|
||||
panelLoading: "Calculating outlier score",
|
||||
panelTitle: "Outlier",
|
||||
panelHead: "of the channel median",
|
||||
@@ -115,6 +188,7 @@ var OBI18n = (function () {
|
||||
popupMinScore: "Hide scores below",
|
||||
popupTtl: "Cache lifetime",
|
||||
popupLanguage: "Language",
|
||||
popupLibrary: "Open library",
|
||||
popupClear: "Clear cache",
|
||||
popupCleared: "Cache cleared.",
|
||||
popupNote: "Score = the video's views divided by the median views of the channel's last N " +
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/* Rozete tıklanınca açılan menü: kopyala, indir, kütüphaneye ekle/çıkar.
|
||||
*
|
||||
* Menü kartın içine değil body'ye eklenir: kart kapsayıcılarının overflow'u
|
||||
* menüyü kırpar. Konum rozetin o anki yerinden ölçülür, kaydırma ve sayfa içi
|
||||
* gezinme menüyü kapatır; takip etmeye çalışmaktan ucuz ve yeterli.
|
||||
*/
|
||||
var OBMenu = (function () {
|
||||
"use strict";
|
||||
|
||||
var T = OBI18n;
|
||||
var open = null;
|
||||
|
||||
function close() {
|
||||
if (open) { open.remove(); open = null; }
|
||||
}
|
||||
|
||||
/* Rozetin kendisi hariç tutulur: rozet tıklaması buraya da düşer ve menü
|
||||
* burada kapatılırsa toggle açık menü göremez, ikinci tık kapatmak yerine
|
||||
* yeniden açardı. */
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!open) return;
|
||||
if (open.contains(e.target) || open.obAnchor.contains(e.target)) return;
|
||||
close();
|
||||
}, true);
|
||||
|
||||
/* Rozet tıklamaları window seviyesinde, capture aşamasında yakalanır ve
|
||||
* yutulur. Rozetin kendi üstünde dinleyici yetmiyordu: YouTube özellikle
|
||||
* Shorts kartlarında tıklamayı belge seviyesindeki capture dinleyicisiyle
|
||||
* bizden önce alıp Short'u açıyordu. Olay yolu window -> document diye
|
||||
* aktığı için buradaki stopPropagation YouTube'unkilerden önce çalışır. */
|
||||
var badgeOf = function (e) {
|
||||
return e.target && e.target.closest ? e.target.closest(".ob-badge") : null;
|
||||
};
|
||||
|
||||
["pointerdown", "pointerup", "mousedown", "mouseup", "touchend"].forEach(function (type) {
|
||||
window.addEventListener(type, function (e) {
|
||||
if (badgeOf(e)) e.stopPropagation();
|
||||
}, true);
|
||||
});
|
||||
|
||||
window.addEventListener("click", function (e) {
|
||||
var badge = badgeOf(e);
|
||||
if (!badge) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
/* Hayalet rozet body'de durur, kartı DOM'dan bulunamaz; kart referansı
|
||||
* hayaletin üstünde taşınır. */
|
||||
var card = badge.obCard || badge.parentElement;
|
||||
while (card && !card.obVideoId) card = card.parentElement;
|
||||
if (card) toggle(badge, contextOf(card));
|
||||
}, true);
|
||||
window.addEventListener("scroll", close, true);
|
||||
window.addEventListener("yt-navigate-start", close);
|
||||
window.addEventListener("resize", close);
|
||||
|
||||
/* --- Karttan bağlam çıkarma ------------------------------------------ */
|
||||
|
||||
/* Başlık kart türüne göre farklı yerlerde; sıra özelden genele. */
|
||||
var TITLE_SELECTORS = [
|
||||
"#video-title",
|
||||
"a#video-title-link",
|
||||
"[class*='lockup-metadata'][class*='title']",
|
||||
".shortsLockupViewModelHostMetadataTitle",
|
||||
"h3 a[href*='/watch']",
|
||||
"h3 a[href*='/shorts/']"
|
||||
];
|
||||
|
||||
function titleOf(card) {
|
||||
for (var i = 0; i < TITLE_SELECTORS.length; i++) {
|
||||
var el = card.querySelector(TITLE_SELECTORS[i]);
|
||||
var text = el && ((el.getAttribute("title") || "").trim() || (el.textContent || "").trim());
|
||||
if (text) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
var CHANNEL_SELECTORS = [
|
||||
"ytd-channel-name a",
|
||||
"ytd-channel-name #text",
|
||||
"[class*='metadata-view-model'][class*='text'] a[href^='/@']",
|
||||
"a[href^='/@']"
|
||||
];
|
||||
|
||||
function channelOf(card) {
|
||||
for (var i = 0; i < CHANNEL_SELECTORS.length; i++) {
|
||||
var el = card.querySelector(CHANNEL_SELECTORS[i]);
|
||||
var text = el && (el.textContent || "").trim();
|
||||
if (text && text.charAt(0) !== "/") return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function contextOf(card) {
|
||||
return {
|
||||
videoId: card.obVideoId,
|
||||
title: titleOf(card),
|
||||
channel: channelOf(card),
|
||||
isShort: /shorts-lockup/i.test(card.tagName) || !!card.querySelector('a[href*="/shorts/"]'),
|
||||
score: card.obScore != null ? card.obScore : null,
|
||||
views: card.obViews != null ? card.obViews : null
|
||||
};
|
||||
}
|
||||
|
||||
/* Kanal sayfası kartlarında başlık bazen sadece görselin aria-label'ında;
|
||||
* hiçbir yerde yoksa son çare InnerTube'a sorulur (tek istek, menü zaten
|
||||
* kullanıcı jestiyle açıldı). */
|
||||
function ensureTitle(ctx) {
|
||||
if (ctx.title) return Promise.resolve(ctx);
|
||||
return OBTube.videoDetails(ctx.videoId).then(function (d) {
|
||||
ctx.title = d.title || "";
|
||||
return ctx;
|
||||
}, function () { return ctx; });
|
||||
}
|
||||
|
||||
/* --- Menü ------------------------------------------------------------ */
|
||||
|
||||
function item(labelKey, action) {
|
||||
var el = document.createElement("button");
|
||||
el.type = "button";
|
||||
el.className = "ob-menu-item";
|
||||
el.textContent = T.t(labelKey);
|
||||
el.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (el.disabled) return;
|
||||
el.disabled = true;
|
||||
el.textContent = "…";
|
||||
action().then(function (doneKey) {
|
||||
el.textContent = T.t(doneKey);
|
||||
/* Zamanlayıcı yalnız kendi menüsünü kapatır: aradan geçen sürede
|
||||
* başka bir rozetin menüsü açıldıysa ona dokunmaz. */
|
||||
var owner = el.parentElement;
|
||||
setTimeout(function () { if (open === owner) close(); }, 900);
|
||||
}, function () {
|
||||
el.disabled = false;
|
||||
el.textContent = T.t("menuFailed");
|
||||
});
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function place(menu, anchor) {
|
||||
var rect = anchor.getBoundingClientRect();
|
||||
menu.style.visibility = "hidden";
|
||||
document.body.appendChild(menu);
|
||||
var width = menu.offsetWidth;
|
||||
var height = menu.offsetHeight;
|
||||
var left = Math.min(rect.right - width, window.innerWidth - width - 8);
|
||||
var top = rect.bottom + 6;
|
||||
if (top + height > window.innerHeight - 8) top = rect.top - height - 6;
|
||||
menu.style.left = Math.max(8, left) + "px";
|
||||
menu.style.top = Math.max(8, top) + "px";
|
||||
menu.style.visibility = "";
|
||||
}
|
||||
|
||||
function toggle(anchor, ctx) {
|
||||
if (open && open.obAnchor === anchor) { close(); return; }
|
||||
close();
|
||||
if (!ctx.videoId) return;
|
||||
|
||||
var menu = document.createElement("div");
|
||||
menu.className = "ob-menu";
|
||||
menu.obAnchor = anchor;
|
||||
|
||||
menu.appendChild(item("menuCopyImage", function () {
|
||||
return ensureTitle(ctx).then(function () {
|
||||
return OBActions.copyImage(ctx.videoId, ctx.title, ctx.channel);
|
||||
}).then(function () { return "menuCopied"; });
|
||||
}));
|
||||
|
||||
menu.appendChild(item("menuCopyText", function () {
|
||||
return ensureTitle(ctx).then(function () {
|
||||
return OBActions.copyText(ctx.videoId, ctx.title, ctx.isShort);
|
||||
}).then(function () { return "menuCopied"; });
|
||||
}));
|
||||
|
||||
menu.appendChild(item("menuDownload", function () {
|
||||
return ensureTitle(ctx).then(function () {
|
||||
return OBActions.download(ctx.videoId, ctx.title, ctx.channel);
|
||||
}).then(function () { return "menuDownloaded"; });
|
||||
}));
|
||||
|
||||
var saved = false;
|
||||
var saveItem = item("menuSave", function () {
|
||||
if (saved) {
|
||||
return OBStore.libraryRemove(ctx.videoId).then(function () {
|
||||
saved = false;
|
||||
return "menuRemoved";
|
||||
});
|
||||
}
|
||||
return ensureTitle(ctx).then(function () {
|
||||
return OBActions.save(ctx);
|
||||
}).then(function () {
|
||||
saved = true;
|
||||
return "menuSaved";
|
||||
});
|
||||
});
|
||||
menu.appendChild(saveItem);
|
||||
OBStore.libraryHas(ctx.videoId).then(function (has) {
|
||||
if (has && !saveItem.disabled) {
|
||||
saved = true;
|
||||
saveItem.textContent = T.t("menuUnsave");
|
||||
}
|
||||
});
|
||||
|
||||
place(menu, anchor);
|
||||
open = menu;
|
||||
}
|
||||
|
||||
return { toggle: toggle, contextOf: contextOf, close: close };
|
||||
})();
|
||||
+1
-9
@@ -139,21 +139,13 @@ var OBPanel = (function () {
|
||||
panel.appendChild(el("div", "ob-note", message));
|
||||
}
|
||||
|
||||
function tone(score) {
|
||||
return score >= 10 ? "ob-t4" : score >= 5 ? "ob-t3"
|
||||
: score >= 2 ? "ob-t2" : score >= 1 ? "ob-t1" : "ob-t0";
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
var panel = mount();
|
||||
if (!panel) return;
|
||||
panel.textContent = "";
|
||||
|
||||
var head = el("div", "ob-panel-head");
|
||||
var scoreText = data.score >= 10
|
||||
? Math.round(data.score) + "x"
|
||||
: T.num(Math.round(data.score * 10) / 10, 1) + "x";
|
||||
head.appendChild(el("span", "ob-chip " + tone(data.score), scoreText));
|
||||
head.appendChild(el("span", "ob-chip " + P.scoreTone(data.score), P.scoreLabel(data.score)));
|
||||
head.appendChild(el("span", "ob-head-text", T.t("panelHead")));
|
||||
panel.appendChild(head);
|
||||
|
||||
|
||||
@@ -133,9 +133,27 @@ var OBParse = (function () {
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
/* Skor -> renk sınıfı ve "2,4x" etiketi. Rozet, izleme paneli ve kütüphane
|
||||
* aynı eşikleri ve yuvarlamayı kullanır; tek kopya burada durur ki eşik
|
||||
* değişince üçü birden değişsin. */
|
||||
function scoreTone(score) {
|
||||
if (score == null) return null;
|
||||
return score >= 10 ? "ob-t4" : score >= 5 ? "ob-t3"
|
||||
: score >= 2 ? "ob-t2" : score >= 1 ? "ob-t1" : "ob-t0";
|
||||
}
|
||||
|
||||
function scoreLabel(score) {
|
||||
if (score >= 10) return Math.round(score) + "x";
|
||||
var rounded = Math.round(score * 10) / 10;
|
||||
if (typeof OBI18n === "undefined") return rounded.toFixed(1) + "x";
|
||||
return OBI18n.num(rounded, 1) + "x";
|
||||
}
|
||||
|
||||
return {
|
||||
findAll: findAll,
|
||||
findFirst: findFirst,
|
||||
scoreTone: scoreTone,
|
||||
scoreLabel: scoreLabel,
|
||||
textOf: textOf,
|
||||
parseCountEn: parseCountEn,
|
||||
parseCountUI: parseCountUI,
|
||||
|
||||
@@ -116,6 +116,58 @@ var OBStore = (function () {
|
||||
return set(payload);
|
||||
}
|
||||
|
||||
/* --- Kütüphane ------------------------------------------------------- */
|
||||
|
||||
/* Kütüphane bellekte önbelleklenmez: kütüphane sayfası ile açık YouTube
|
||||
* sekmeleri aynı veriyi düzenliyor, bayat kopya yanlış "ekli/değil" gösterir.
|
||||
* Erişim seyrek (menü açılışı, kayıt), her seferinde depodan okumak ucuz. */
|
||||
var LIBRARY_KEY = "library";
|
||||
|
||||
function readLibrary() {
|
||||
return get(LIBRARY_KEY).then(function (r) {
|
||||
var lib = r[LIBRARY_KEY] || {};
|
||||
return {
|
||||
v: 1,
|
||||
projects: Array.isArray(lib.projects) ? lib.projects : [],
|
||||
items: Array.isArray(lib.items) ? lib.items : []
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function writeLibrary(lib) {
|
||||
var payload = {};
|
||||
payload[LIBRARY_KEY] = lib;
|
||||
return set(payload);
|
||||
}
|
||||
|
||||
/* Aynı video ikinci kez eklenirse eski kaydın yerine geçer (tek girdi,
|
||||
* proje bilgisi korunur), listenin başına alınır. */
|
||||
function libraryAdd(item) {
|
||||
return readLibrary().then(function (lib) {
|
||||
var old = null;
|
||||
lib.items = lib.items.filter(function (it) {
|
||||
if (it.videoId === item.videoId) { old = it; return false; }
|
||||
return true;
|
||||
});
|
||||
if (old && item.projectId == null) item.projectId = old.projectId;
|
||||
lib.items.unshift(item);
|
||||
return writeLibrary(lib).then(function () { return lib; });
|
||||
});
|
||||
}
|
||||
|
||||
function libraryRemove(videoId) {
|
||||
return readLibrary().then(function (lib) {
|
||||
lib.items = lib.items.filter(function (it) { return it.videoId !== videoId; });
|
||||
return writeLibrary(lib);
|
||||
});
|
||||
}
|
||||
|
||||
function libraryHas(videoId) {
|
||||
return readLibrary().then(function (lib) {
|
||||
return lib.items.some(function (it) { return it.videoId === videoId; });
|
||||
});
|
||||
}
|
||||
|
||||
function clearBaselines() {
|
||||
mem = {};
|
||||
return get("blIndex").then(function (r) {
|
||||
@@ -139,6 +191,11 @@ var OBStore = (function () {
|
||||
writeBaseline: writeBaseline,
|
||||
readChannelId: readChannelId,
|
||||
writeChannelId: writeChannelId,
|
||||
readLibrary: readLibrary,
|
||||
writeLibrary: writeLibrary,
|
||||
libraryAdd: libraryAdd,
|
||||
libraryRemove: libraryRemove,
|
||||
libraryHas: libraryHas,
|
||||
clearBaselines: clearBaselines,
|
||||
stats: stats
|
||||
};
|
||||
|
||||
+48
-4
@@ -8,6 +8,10 @@
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 30px;
|
||||
/* Mütevazı kalmalı: yüksek bir değer rozeti YouTube'un kendi katmanlarının
|
||||
(açıklama penceresi gibi dialoglar ~2200) üstüne taşırıyor. Hover
|
||||
önizlemesinin üstünde kalma işi kartın içindeki bu rozetin değil,
|
||||
body'ye basılan hayalet kopyanın. */
|
||||
z-index: 20;
|
||||
min-width: 34px;
|
||||
padding: 3px 7px;
|
||||
@@ -18,14 +22,21 @@
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Shorts kapağının altı izlenme metniyle dolu, orada rozet üste gider. */
|
||||
.ob-badge.ob-short {
|
||||
top: 6px;
|
||||
.ob-badge:hover {
|
||||
outline: 1px solid rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
/* Hayalet rozet: hover önizleme katmanının üstünde duran fixed kopya;
|
||||
left/top JS'ten inline verilir. */
|
||||
.ob-badge.ob-ghost {
|
||||
position: fixed;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
z-index: 2147483647;
|
||||
}
|
||||
|
||||
.ob-badge.ob-loading {
|
||||
@@ -52,6 +63,39 @@
|
||||
.ob-t3 { background: rgba(180, 83, 9, 0.94); }
|
||||
.ob-t4 { background: rgba(159, 18, 57, 0.95); }
|
||||
|
||||
/* --- Rozet menüsü --- */
|
||||
|
||||
/* Menü de rozet gibi tema bağımsız koyu: sayfanın her yerinde aynı görünür. */
|
||||
.ob-menu {
|
||||
position: fixed;
|
||||
z-index: 2147483000;
|
||||
min-width: 190px;
|
||||
padding: 5px;
|
||||
border-radius: 9px;
|
||||
background: #212121;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
font-family: "Roboto", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.ob-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 11px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #eee;
|
||||
font: 500 13px/1.3 inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ob-menu-item:hover:not(:disabled) { background: rgba(255, 255, 255, 0.12); }
|
||||
.ob-menu-item:disabled { color: #9aa0a6; cursor: default; }
|
||||
|
||||
/* --- Izleme sayfasi paneli --- */
|
||||
|
||||
.ob-panel {
|
||||
|
||||
Reference in New Issue
Block a user