metube/extension/content.js
PepegaSan ce30a9ea80 Add browser extension to mark clip times and queue to MeTube
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:22:29 +02:00

622 lines
16 KiB
JavaScript

const CLIPS_KEY = 'clipDraftByUrl';
let contextTeardownDone = false;
let navIntervalId = null;
let syncIntervalId = null;
/** After extension reload, old tabs must refresh (F5). */
function isExtensionContextValid() {
try {
return typeof chrome !== 'undefined' && !!chrome.runtime?.id;
} catch {
return false;
}
}
function handleInvalidExtensionContext() {
if (contextTeardownDone) {
return true;
}
if (isExtensionContextValid()) {
return false;
}
contextTeardownDone = true;
if (navIntervalId != null) {
clearInterval(navIntervalId);
navIntervalId = null;
}
if (syncIntervalId != null) {
clearInterval(syncIntervalId);
syncIntervalId = null;
}
if (overlayRoot) {
try {
overlayRoot.remove();
} catch {
/* ignore */
}
overlayRoot = null;
}
if (!document.getElementById('metube-reload-hint')) {
const hint = document.createElement('div');
hint.id = 'metube-reload-hint';
hint.textContent = 'MeTube extension updated — reload this page (F5)';
hint.style.cssText =
'position:fixed;bottom:12px;right:12px;z-index:2147483647;padding:10px 14px;' +
'background:#b02a37;color:#fff;border-radius:8px;font:13px system-ui,sans-serif;' +
'box-shadow:0 4px 12px rgba(0,0,0,.4);max-width:280px;';
document.documentElement.appendChild(hint);
}
return true;
}
function isFiniteNum(x) {
return typeof x === 'number' && x === x && x !== Infinity && x !== -Infinity;
}
/** chrome.storage.local — always via callback (content scripts). */
function mtStorageLocalGet(keys) {
if (handleInvalidExtensionContext()) {
return Promise.resolve({});
}
return new Promise((resolve) => {
if (!chrome?.storage?.local) {
resolve({});
return;
}
try {
chrome.storage.local.get(keys, (data) => {
if (handleInvalidExtensionContext()) {
resolve({});
return;
}
resolve(chrome.runtime.lastError ? {} : data || {});
});
} catch {
handleInvalidExtensionContext();
resolve({});
}
});
}
function mtStorageLocalSet(items) {
if (handleInvalidExtensionContext()) {
return Promise.resolve();
}
return new Promise((resolve) => {
if (!chrome?.storage?.local) {
resolve();
return;
}
try {
chrome.storage.local.set(items, () => {
resolve();
});
} catch {
handleInvalidExtensionContext();
resolve();
}
});
}
function mtStorageLocalRemove(keys) {
if (handleInvalidExtensionContext()) {
return Promise.resolve();
}
return new Promise((resolve) => {
if (!chrome?.storage?.local) {
resolve();
return;
}
try {
chrome.storage.local.remove(keys, () => {
resolve();
});
} catch {
handleInvalidExtensionContext();
resolve();
}
});
}
function normalizeStorageKey(href) {
try {
const u = new URL(href);
const host = u.hostname.replace(/^www\./, '').replace(/^m\./, '');
if (host === 'youtu.be') {
const id = u.pathname.split('/').filter(Boolean)[0];
if (id) return `youtube:${id}`;
}
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
const id = u.searchParams.get('v');
if (id) return `youtube:${id}`;
}
u.hash = '';
return `${u.origin}${u.pathname}${u.search}`;
} catch {
return href;
}
}
function pageUrlForMeTube(href) {
try {
const u = new URL(href);
u.hash = '';
const host = u.hostname.replace(/^www\./, '').replace(/^m\./, '');
if (host === 'youtube.com' || host.endsWith('.youtube.com') || host === 'youtu.be') {
u.searchParams.delete('t');
u.searchParams.delete('start');
}
return u.toString();
} catch {
return href;
}
}
function safeVideoTime(video) {
if (!video || !video.isConnected) {
return 0;
}
if (typeof HTMLVideoElement !== 'undefined' && !(video instanceof HTMLVideoElement)) {
return 0;
}
try {
if (video.readyState < 1) {
return 0;
}
const t = video.currentTime;
return isFiniteNum(t) && t >= 0 ? t : 0;
} catch {
return 0;
}
}
function formatClipTime(seconds) {
const n = typeof seconds === 'number' ? seconds : parseFloat(String(seconds));
if (!isFiniteNum(n) || n < 0) {
return '0:00';
}
const total = Math.floor(n);
const s = total % 60;
const m = Math.floor(total / 60) % 60;
const h = Math.floor(total / 3600);
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}
function videoArea(v) {
try {
if (!v?.isConnected) return 0;
return v.clientWidth * v.clientHeight;
} catch {
return 0;
}
}
function getActiveVideo() {
let candidates = [];
try {
candidates = Array.from(document.querySelectorAll('video')).filter((v) => {
try {
return v.isConnected && v.readyState >= 1;
} catch {
return false;
}
});
} catch {
return null;
}
if (!candidates.length) {
return null;
}
return candidates.reduce((best, v) => (videoArea(v) > videoArea(best) ? v : best));
}
function shouldShowBar() {
const v = getActiveVideo();
if (!v) return false;
try {
if (window.top === window.self) return true;
} catch {
return true;
}
return v.clientWidth >= 240 && v.clientHeight >= 135;
}
let pendingStart = null;
let overlayRoot = null;
let sessionBarHidden = false;
/** @type {Record<string, { start: string, end: string }[]>} */
const sessionClipsByKey = {};
function storageKey() {
return normalizeStorageKey(location.href);
}
function pendingStorageItemKey() {
return `pending:${storageKey()}`;
}
function loadPendingFromStorage() {
const key = pendingStorageItemKey();
return mtStorageLocalGet(key).then((data) => {
const value = data[key];
return typeof value === 'string' && value ? value : null;
});
}
function savePendingToStorage(value) {
const key = pendingStorageItemKey();
if (value) {
return mtStorageLocalSet({ [key]: value });
}
return mtStorageLocalRemove(key);
}
function ensurePendingLoaded() {
if (pendingStart) {
return Promise.resolve(pendingStart);
}
return loadPendingFromStorage().then((stored) => {
pendingStart = stored;
return pendingStart;
});
}
async function loadClipsForPage() {
const key = storageKey();
if (sessionClipsByKey[key]?.length) {
return [...sessionClipsByKey[key]];
}
const data = await mtStorageLocalGet(CLIPS_KEY);
const all = data[CLIPS_KEY] || {};
const list = all[key] ? [...all[key]] : [];
sessionClipsByKey[key] = list;
return list;
}
async function appendClip(clip) {
const key = storageKey();
const list = sessionClipsByKey[key] ? [...sessionClipsByKey[key]] : [];
list.push(clip);
sessionClipsByKey[key] = list;
const data = await mtStorageLocalGet(CLIPS_KEY);
const all = data[CLIPS_KEY] || {};
all[key] = list;
await mtStorageLocalSet({ [CLIPS_KEY]: all });
return list;
}
function updateOverlayUiNow() {
if (!overlayRoot) return;
const statusEl = overlayRoot.querySelector('[data-role="status"]');
const btnEnd = overlayRoot.querySelector('[data-action="end"]');
const btnClear = overlayRoot.querySelector('[data-action="clear"]');
if (!statusEl || !btnEnd || !btnClear) return;
btnEnd.disabled = !pendingStart;
btnClear.hidden = !pendingStart;
const key = storageKey();
const clipCount = sessionClipsByKey[key]?.length ?? 0;
if (pendingStart) {
statusEl.textContent = `Start ${pendingStart} — seek to end, then End`;
} else if (clipCount > 0) {
statusEl.textContent = `${clipCount} clip(s) — open extension popup to queue`;
} else {
const v = getActiveVideo();
statusEl.textContent = v ? `Jetzt: ${formatClipTime(safeVideoTime(v))}` : 'Kein Video';
}
}
function doMarkStart() {
const video = getActiveVideo();
if (!video) {
return Promise.resolve({ ok: false, error: 'no_video' });
}
pendingStart = formatClipTime(safeVideoTime(video));
updateOverlayUiNow();
return savePendingToStorage(pendingStart).then(() => {
updateOverlayUiNow();
syncOverlayState();
return {
ok: true,
pendingStart,
pageUrl: pageUrlForMeTube(location.href),
pageKey: storageKey(),
};
});
}
function doMarkEnd() {
return ensurePendingLoaded().then((start) => {
const video = getActiveVideo();
if (!video) {
return { ok: false, error: 'no_video' };
}
if (!start) {
return { ok: false, error: 'no_pending_start' };
}
const end = formatClipTime(safeVideoTime(video));
const clip = { start, end };
pendingStart = null;
updateOverlayUiNow();
return savePendingToStorage(null)
.then(() => appendClip(clip))
.then((clips) => {
updateOverlayUiNow();
syncOverlayState();
return {
ok: true,
clip,
clips,
pageUrl: pageUrlForMeTube(location.href),
pageKey: storageKey(),
};
});
});
}
function doClearPending() {
pendingStart = null;
updateOverlayUiNow();
return savePendingToStorage(null).then(() => {
updateOverlayUiNow();
return { ok: true };
});
}
function getVideoStateResponse() {
const pageUrl = pageUrlForMeTube(location.href);
const pageKey = storageKey();
return ensurePendingLoaded().then(async (pending) => {
const clips = await loadClipsForPage();
const video = getActiveVideo();
if (!video) {
return {
ok: false,
error: 'no_video',
pageUrl,
pageKey,
pendingStart: pending,
clips,
};
}
return {
ok: true,
currentTime: safeVideoTime(video),
duration: isFiniteNum(video.duration) ? video.duration : 0,
formatted: formatClipTime(safeVideoTime(video)),
pageUrl,
pageKey,
pendingStart: pending,
clips,
};
});
}
function injectOverlayStyles() {
if (document.getElementById('metube-clip-bar-style')) return;
const style = document.createElement('style');
style.id = 'metube-clip-bar-style';
style.textContent = `
#metube-clip-bar {
position: fixed;
right: 12px;
bottom: 72px;
z-index: 2147483647;
isolation: isolate;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
max-width: min(420px, calc(100vw - 24px));
padding: 8px 10px;
border-radius: 10px;
background: rgba(20, 20, 24, 0.92);
color: #f2f2f2;
font: 12px/1.3 system-ui, sans-serif;
box-shadow: 0 4px 20px rgba(0,0,0,0.45);
pointer-events: auto;
user-select: none;
}
#metube-clip-bar button {
cursor: pointer;
border: 1px solid #555;
border-radius: 6px;
padding: 6px 10px;
background: #2d2d35;
color: #fff;
font: inherit;
pointer-events: auto;
position: relative;
z-index: 1;
}
#metube-clip-bar button:hover:not(:disabled) {
background: #3d3d48;
}
#metube-clip-bar button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
#metube-clip-bar button.metube-primary {
background: #0d6efd;
border-color: #0d6efd;
}
#metube-clip-bar .metube-status {
flex: 1 1 100%;
color: #9ecbff;
min-height: 1.2em;
}
#metube-clip-bar .metube-hide {
padding: 4px 8px;
font-size: 11px;
}
`;
(document.head || document.documentElement).appendChild(style);
}
function ensureOverlay() {
if (overlayRoot || !shouldShowBar()) return;
injectOverlayStyles();
const bar = document.createElement('div');
bar.id = 'metube-clip-bar';
bar.innerHTML = `
<button type="button" class="metube-primary" data-action="start">Start</button>
<button type="button" data-action="end" disabled>End</button>
<button type="button" data-action="clear" hidden>Cancel</button>
<span class="metube-status" data-role="status"></span>
<button type="button" class="metube-hide" data-action="hide" title="Hide bar">✕</button>
`;
function bindBarButton(selector, handler) {
const btn = bar.querySelector(selector);
let busy = false;
btn.addEventListener(
'click',
(e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (busy) return;
busy = true;
Promise.resolve(handler(e)).finally(() => {
busy = false;
});
},
true,
);
}
bindBarButton('[data-action="start"]', () => {
doMarkStart().catch(() => updateOverlayUiNow());
});
bindBarButton('[data-action="end"]', () => {
doMarkEnd().catch(() => updateOverlayUiNow());
});
bindBarButton('[data-action="clear"]', () => {
doClearPending().catch(() => updateOverlayUiNow());
});
bindBarButton('[data-action="hide"]', () => {
sessionBarHidden = true;
bar.remove();
overlayRoot = null;
mtStorageLocalSet({ metubeBarHidden: true });
});
document.documentElement.appendChild(bar);
overlayRoot = bar;
syncOverlayState();
}
function syncOverlayState() {
if (!overlayRoot) return;
try {
const statusEl = overlayRoot.querySelector('[data-role="status"]');
const btnEnd = overlayRoot.querySelector('[data-action="end"]');
const btnClear = overlayRoot.querySelector('[data-action="clear"]');
ensurePendingLoaded()
.then(async (pending) => {
const clips = await loadClipsForPage();
btnEnd.disabled = !pending;
btnClear.hidden = !pending;
if (pending) {
statusEl.textContent = `Start ${pending} — seek to end, then End`;
} else if (clips.length) {
statusEl.textContent = `${clips.length} clip(s) — open extension popup to queue`;
} else {
const v = getActiveVideo();
statusEl.textContent = v
? `Jetzt: ${formatClipTime(safeVideoTime(v))}`
: 'Kein Video';
}
})
.catch(() => {
if (statusEl) statusEl.textContent = 'Video nicht bereit';
});
} catch {
/* ignore — some players break DOM access */
}
}
function initBar() {
if (sessionBarHidden) return;
mtStorageLocalGet('metubeBarHidden').then((data) => {
if (data.metubeBarHidden || sessionBarHidden) return;
if (shouldShowBar()) {
ensureOverlay();
return;
}
const obs = new MutationObserver(() => {
if (shouldShowBar()) {
obs.disconnect();
ensureOverlay();
}
});
obs.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => obs.disconnect(), 60000);
});
}
let lastHref = location.href;
navIntervalId = setInterval(() => {
if (handleInvalidExtensionContext()) {
return;
}
if (location.href !== lastHref) {
lastHref = location.href;
pendingStart = null;
if (overlayRoot) syncOverlayState();
else initBar();
}
}, 800);
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (handleInvalidExtensionContext()) {
sendResponse({ ok: false, error: 'context_invalidated', hint: 'reload_tab' });
return true;
}
if (msg?.action === 'getVideoState') {
getVideoStateResponse().then(sendResponse);
return true;
}
if (msg?.action === 'markStart') {
doMarkStart().then(sendResponse);
return true;
}
if (msg?.action === 'markEnd') {
doMarkEnd().then(sendResponse);
return true;
}
if (msg?.action === 'clearPending') {
doClearPending().then(sendResponse);
return true;
}
if (msg?.action === 'showBar') {
mtStorageLocalSet({ metubeBarHidden: false }).then(() => {
initBar();
sendResponse({ ok: true });
});
return true;
}
return false;
});
if (!handleInvalidExtensionContext()) {
initBar();
}
syncIntervalId = setInterval(() => {
if (handleInvalidExtensionContext()) {
return;
}
if (!overlayRoot && shouldShowBar()) {
mtStorageLocalGet('metubeBarHidden').then((d) => {
if (!d.metubeBarHidden) ensureOverlay();
});
} else if (overlayRoot) {
syncOverlayState();
}
}, 2000);