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:
2026-08-30 16:43:32 +03:00
parent 5e35cf1074
commit 0ed9dbf41a
21 changed files with 1616 additions and 48 deletions
+256
View File
@@ -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
};
})();