mirror of
https://github.com/yusufipk/YouTubeOutlierBadge.git
synced 2026-09-11 10:56:14 +00:00
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.
540 lines
18 KiB
JavaScript
540 lines
18 KiB
JavaScript
/* 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);
|
||
});
|
||
})();
|