Add browser extension to mark clip times and queue to MeTube

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PepegaSan 2026-05-24 02:22:29 +02:00
parent 5d96a581b9
commit ce30a9ea80
15 changed files with 1305 additions and 0 deletions

View file

@ -238,6 +238,8 @@ __Chrome:__ contributed by [Rpsl](https://github.com/rpsl). You can install it f
__Firefox:__ contributed by [nanocortex](https://github.com/nanocortex). You can install it from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/metube-downloader) or get sources from [here](https://github.com/nanocortex/metube-firefox-addon).
**Clip marker (optional, in-repo):** load unpacked from [`extension/`](extension/) to set start/end times on the video player and queue partial downloads (requires batch-clip API support). See [extension/README.md](extension/README.md).
## 📱 iOS Shortcut
[rithask](https://github.com/rithask) created an iOS shortcut to send URLs to MeTube from Safari. Enter the MeTube instance address when prompted which will be saved for later use. You can run the shortcut from Safaris share menu. The shortcut can be downloaded from [this iCloud link](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6).

2
extension/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.DS_Store
Thumbs.db

23
extension/README.md Normal file
View file

@ -0,0 +1,23 @@
# MeTube Clip Marker
Chrome extension (Manifest V3) — mark clip times on **any page with a `<video>` element** and queue them to your MeTube instance.
## Requirements
- MeTube with **batch clips** support (`POST /add-batch`) and single-clip `POST /add`
- `CORS_ALLOWED_ORIGINS=*` on the MeTube server
## Install (unpacked)
1. Open `chrome://extensions/`
2. Enable **Developer mode**
3. **Load unpacked** → select this `extension/` folder
4. Open **Extension options** → set your MeTube URL (default `http://localhost:8081/`)
## Usage
1. Open a page with a video player and reload the tab after installing or updating the extension.
2. Use the floating bar at the bottom-right (**Start** / **End**).
3. Open the extension popup → **Queue clips** or **Queue merged** (merge needs at least two clips).
Clip times are stored per page until you remove them or close the browser.

63
extension/background.js Normal file
View file

@ -0,0 +1,63 @@
import { apiUrl, buildQueueBody } from './lib/metube-api.js';
import { loadSettings } from './lib/storage.js';
async function getActiveTabId() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab?.id;
}
async function sendToTab(tabId, payload) {
try {
return await chrome.tabs.sendMessage(tabId, payload);
} catch (err) {
const msg = String(err?.message || err);
if (msg.includes('Receiving end does not exist')) {
return { ok: false, error: 'no_content_script', hint: 'reload_tab' };
}
return { ok: false, error: msg };
}
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.action === 'queueClips') {
queueClips(msg.pageUrl, msg.clips, msg.mergeClips)
.then((result) => sendResponse(result))
.catch((err) => sendResponse({ ok: false, error: String(err?.message || err) }));
return true;
}
const tabActions = ['getVideoState', 'markStart', 'markEnd', 'clearPending', 'showBar'];
if (tabActions.includes(msg?.action)) {
(async () => {
const tabId = msg.tabId ?? (await getActiveTabId());
if (!tabId) {
sendResponse({ ok: false, error: 'no_tab' });
return;
}
const result = await sendToTab(tabId, { action: msg.action });
sendResponse(result);
})();
return true;
}
return false;
});
async function queueClips(pageUrl, clips, mergeClips) {
if (!clips?.length) {
return { ok: false, error: 'no_clips' };
}
const settings = await loadSettings();
const { endpoint, body } = buildQueueBody(settings, pageUrl, clips, !!mergeClips);
const url = apiUrl(settings.metubeBaseUrl, endpoint);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) {
return { ok: false, error: text || res.statusText };
}
return { ok: true, body: text };
}

622
extension/content.js Normal file
View file

@ -0,0 +1,622 @@
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);

View file

@ -0,0 +1,50 @@
/**
* @param {string} baseUrl e.g. http://localhost:8081/
* @param {string} path e.g. add-batch
*/
export function apiUrl(baseUrl, path) {
const base = baseUrl.replace(/\/+$/, '') + '/';
const p = path.replace(/^\/+/, '');
return new URL(p, base).toString();
}
/**
* @param {import('./storage.js').ExtensionSettings} settings
* @param {string} pageUrl
* @param {{ start: string, end: string }[]} clips
* @param {boolean} mergeClips
*/
export function buildQueueBody(settings, pageUrl, clips, mergeClips) {
const body = {
url: pageUrl,
download_type: settings.downloadType,
codec: settings.codec,
quality: settings.quality,
format: settings.format,
folder: settings.folder,
custom_name_prefix: settings.customNamePrefix,
playlist_item_limit: settings.playlistItemLimit,
auto_start: settings.autoStart,
split_by_chapters: false,
chapter_template: '',
subtitle_language: settings.subtitleLanguage,
subtitle_mode: settings.subtitleMode,
ytdl_options_presets: [],
ytdl_options_overrides: '',
};
if (clips.length === 1 && !mergeClips) {
body.clip_start = clips[0].start;
body.clip_end = clips[0].end;
return { endpoint: 'add', body };
}
return {
endpoint: 'add-batch',
body: {
...body,
clip_start: null,
clip_end: null,
merge_clips: mergeClips,
clips: clips.map((c) => ({ start: c.start, end: c.end })),
},
};
}

44
extension/lib/page-key.js Normal file
View file

@ -0,0 +1,44 @@
/**
* Stable key for clip drafts / pending start (URL bar may gain &t=, www/m., etc.).
* @param {string} href
*/
export 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;
}
}
/** URL sent to MeTube (no hash; strip &t= on YouTube when we send explicit clips). */
export 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;
}
}

69
extension/lib/storage.js Normal file
View file

@ -0,0 +1,69 @@
import { normalizeStorageKey } from './page-key.js';
/** @typedef {object} ExtensionSettings
* @property {string} metubeBaseUrl
* @property {string} downloadType
* @property {string} codec
* @property {string} quality
* @property {string} format
* @property {string} folder
* @property {string} customNamePrefix
* @property {number} playlistItemLimit
* @property {boolean} autoStart
* @property {string} subtitleLanguage
* @property {string} subtitleMode
*/
export const DEFAULT_SETTINGS = {
metubeBaseUrl: 'http://localhost:8081/',
downloadType: 'video',
codec: 'auto',
quality: 'best',
format: 'any',
folder: '',
customNamePrefix: '',
playlistItemLimit: 0,
autoStart: true,
subtitleLanguage: 'en',
subtitleMode: 'prefer_manual',
};
/** @returns {Promise<ExtensionSettings>} */
export async function loadSettings() {
const data = await chrome.storage.sync.get(DEFAULT_SETTINGS);
return { ...DEFAULT_SETTINGS, ...data };
}
/** @param {Partial<ExtensionSettings>} patch */
export async function saveSettings(patch) {
await chrome.storage.sync.set(patch);
}
const CLIPS_KEY = 'clipDraftByUrl';
/** @param {string} pageUrlOrKey */
export function keyForPage(pageUrlOrKey) {
if (pageUrlOrKey.startsWith('youtube:')) {
return pageUrlOrKey;
}
return normalizeStorageKey(pageUrlOrKey);
}
/** @returns {Promise<Record<string, { start: string, end: string }[]>>} */
export async function loadAllClipDrafts() {
const data = await chrome.storage.local.get(CLIPS_KEY);
return data[CLIPS_KEY] || {};
}
/** @param {string} pageKey @param {{ start: string, end: string }[]} clips */
export async function saveClipDraft(pageKey, clips) {
const all = await loadAllClipDrafts();
all[pageKey] = clips;
await chrome.storage.local.set({ [CLIPS_KEY]: all });
}
/** @param {string} pageKey */
export async function loadClipDraft(pageKey) {
const all = await loadAllClipDrafts();
return all[pageKey] ? [...all[pageKey]] : [];
}

14
extension/lib/time.js Normal file
View file

@ -0,0 +1,14 @@
/** @param {number} seconds */
export function formatClipTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) {
return '0:00';
}
const total = Math.floor(seconds);
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')}`;
}

27
extension/manifest.json Normal file
View file

@ -0,0 +1,27 @@
{
"manifest_version": 3,
"name": "MeTube Clip Marker",
"version": "0.2.9",
"description": "Mark clip times on any page with a video element and send them to your MeTube instance.",
"permissions": ["storage", "activeTab", "scripting"],
"host_permissions": ["<all_urls>"],
"action": {
"default_popup": "popup.html",
"default_title": "MeTube clips"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}

45
extension/options.html Normal file
View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>MeTube Extension Einstellungen</title>
<style>
body { font: 14px system-ui, sans-serif; max-width: 520px; margin: 24px auto; padding: 0 16px; }
label { display: block; margin-top: 12px; font-weight: 600; }
input, select { width: 100%; margin-top: 4px; padding: 8px; box-sizing: border-box; }
button { margin-top: 20px; padding: 10px 16px; }
.saved { color: green; margin-left: 8px; }
p.note { color: #555; font-size: 13px; }
</style>
</head>
<body>
<h1>MeTube Extension</h1>
<p class="note">Set <code>CORS_ALLOWED_ORIGINS=*</code> on your MeTube server. Queue requests use these defaults.</p>
<label>MeTube URL
<input type="url" id="metubeBaseUrl" placeholder="http://localhost:8081/" />
</label>
<label>Download type
<select id="downloadType">
<option value="video">Video</option>
<option value="audio">Audio</option>
</select>
</label>
<label>Quality
<input type="text" id="quality" value="best" />
</label>
<label>Codec
<input type="text" id="codec" value="auto" />
</label>
<label>Format
<input type="text" id="format" value="any" />
</label>
<label>Auto-start downloads
<select id="autoStart">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</label>
<button type="button" id="save">Save</button><span id="saved" class="saved" hidden>Saved</span>
<script type="module" src="options.js"></script>
</body>
</html>

37
extension/options.js Normal file
View file

@ -0,0 +1,37 @@
import { DEFAULT_SETTINGS, loadSettings, saveSettings } from './lib/storage.js';
const fields = ['metubeBaseUrl', 'downloadType', 'quality', 'codec', 'format', 'autoStart'];
async function init() {
const s = await loadSettings();
for (const key of fields) {
const el = document.getElementById(key);
if (!el) continue;
const val = s[key];
if (el.tagName === 'SELECT') {
el.value = String(val);
} else {
el.value = String(val ?? DEFAULT_SETTINGS[key] ?? '');
}
}
}
document.getElementById('save').addEventListener('click', async () => {
/** @type {Record<string, unknown>} */
const patch = {};
for (const key of fields) {
const el = document.getElementById(key);
if (!el) continue;
if (key === 'autoStart') {
patch[key] = el.value === 'true';
} else {
patch[key] = el.value;
}
}
await saveSettings(patch);
const saved = document.getElementById('saved');
saved.hidden = false;
setTimeout(() => { saved.hidden = true; }, 2000);
});
init();

110
extension/popup.css Normal file
View file

@ -0,0 +1,110 @@
body {
width: 320px;
margin: 0;
padding: 12px;
font: 13px/1.4 system-ui, sans-serif;
color: #1a1a1a;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
h1 {
font-size: 15px;
margin: 0;
}
#optionsLink {
text-decoration: none;
font-size: 18px;
}
.status {
margin: 0 0 8px;
color: #444;
}
.url {
font-size: 11px;
word-break: break-all;
color: #666;
margin: 0 0 8px;
}
.markers {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 8px;
}
button {
cursor: pointer;
border: 1px solid #ccc;
border-radius: 6px;
padding: 6px 10px;
background: #f5f5f5;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: #0d6efd;
color: #fff;
border-color: #0d6efd;
width: 100%;
}
.pending {
font-size: 12px;
color: #0d6efd;
margin: 0 0 8px;
min-height: 1.2em;
}
#btnCancelPending {
font-size: 12px;
}
.clips {
list-style: none;
padding: 0;
margin: 0 0 10px;
max-height: 140px;
overflow-y: auto;
}
.clips li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid #eee;
font-size: 12px;
}
.clips button {
padding: 2px 6px;
font-size: 11px;
}
.actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.hint-top {
font-size: 11px;
color: #333;
background: #fff8e6;
border: 1px solid #ffe08a;
border-radius: 6px;
padding: 8px;
margin: 0 0 8px;
}
.btn-show-bar {
width: 100%;
margin-bottom: 8px;
font-size: 12px;
}
.hint {
font-size: 11px;
color: #777;
margin: 10px 0 0;
}
code {
font-size: 10px;
}

32
extension/popup.html Normal file
View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="popup.css" />
<title>MeTube Clip Marker</title>
</head>
<body>
<header>
<h1>MeTube Clip Marker</h1>
<a href="#" id="optionsLink" title="Settings"></a>
</header>
<p id="status" class="status"></p>
<p class="hint-top"><strong>Tip:</strong> use the bar at the <strong>bottom-right of the video page</strong> (Start / End). The popup may close when you click the video.</p>
<button type="button" id="btnShowBar" class="btn-show-bar">Show bar on page</button>
<p id="pageUrl" class="url" hidden></p>
<div class="markers">
<button type="button" id="btnStart" disabled>Set start</button>
<button type="button" id="btnEnd" disabled>Set end</button>
<button type="button" id="btnCancelPending" hidden>Discard start</button>
</div>
<p id="pending" class="pending"></p>
<ul id="clipList" class="clips"></ul>
<div class="actions">
<button type="button" id="btnQueueEach" class="primary" disabled>Queue clips</button>
<button type="button" id="btnQueueMerge" disabled>Queue merged</button>
</div>
<p class="hint">Works on any page with a <code>&lt;video&gt;</code> element. MeTube needs <code>CORS_ALLOWED_ORIGINS=*</code>.</p>
<script type="module" src="popup.js"></script>
</body>
</html>

165
extension/popup.js Normal file
View file

@ -0,0 +1,165 @@
import { loadSettings, saveClipDraft } from './lib/storage.js';
const statusEl = document.getElementById('status');
const pageUrlEl = document.getElementById('pageUrl');
const pendingEl = document.getElementById('pending');
const clipListEl = document.getElementById('clipList');
const btnStart = document.getElementById('btnStart');
const btnEnd = document.getElementById('btnEnd');
const btnQueueEach = document.getElementById('btnQueueEach');
const btnQueueMerge = document.getElementById('btnQueueMerge');
const btnCancelPending = document.getElementById('btnCancelPending');
const btnShowBar = document.getElementById('btnShowBar');
const optionsLink = document.getElementById('optionsLink');
let pageUrl = null;
let pageKey = null;
let clips = [];
let pendingStart = null;
optionsLink.href = chrome.runtime.getURL('options.html');
optionsLink.addEventListener('click', (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
function sendBg(action) {
return chrome.runtime.sendMessage({ action });
}
function setStatus(text) {
statusEl.textContent = text;
}
function renderClips() {
clipListEl.innerHTML = '';
clips.forEach((clip, index) => {
const li = document.createElement('li');
li.innerHTML = `<span>${clip.start}${clip.end}</span>`;
const del = document.createElement('button');
del.type = 'button';
del.textContent = '×';
del.addEventListener('click', async () => {
clips.splice(index, 1);
if (pageKey) await saveClipDraft(pageKey, clips);
renderClips();
updateButtons();
});
li.appendChild(del);
clipListEl.appendChild(li);
});
}
function updateButtons() {
const hasClips = clips.length > 0;
btnQueueEach.disabled = !pageUrl || !hasClips;
btnQueueMerge.disabled = !pageUrl || clips.length < 2;
if (btnCancelPending) {
btnCancelPending.hidden = !pendingStart;
btnCancelPending.disabled = !pendingStart;
}
if (btnEnd) btnEnd.disabled = !pendingStart;
pendingEl.textContent = pendingStart ? `Start: ${pendingStart}` : '';
}
function applyFromState(state) {
if (state?.pageUrl) {
pageUrl = state.pageUrl;
pageUrlEl.hidden = false;
pageUrlEl.textContent = pageUrl;
}
if (state?.pageKey) pageKey = state.pageKey;
if (state?.pendingStart) pendingStart = state.pendingStart;
else if (state && 'pendingStart' in state) pendingStart = null;
if (Array.isArray(state?.clips)) clips = [...state.clips];
}
async function refresh() {
const state = await sendBg('getVideoState');
if (state?.error === 'context_invalidated' || state?.hint === 'reload_tab') {
setStatus('Extension wurde aktualisiert — diese Seite einmal neu laden (F5).');
btnStart.disabled = true;
btnEnd.disabled = true;
return;
}
applyFromState(state);
if (state?.ok) {
setStatus(`Aktuell: ${state.formatted} — oder Leiste auf der Seite`);
btnStart.disabled = false;
} else if (pendingStart) {
setStatus('Start saved — set end on the page bar or here');
btnStart.disabled = false;
} else if (clips.length) {
setStatus(`${clips.length} clip(s) — queue below`);
btnStart.disabled = false;
} else {
setStatus(state?.error === 'no_video' ? 'Kein Video — Seite neu laden' : 'Verbinde…');
btnStart.disabled = !state?.pageUrl;
}
renderClips();
updateButtons();
}
btnStart?.addEventListener('mousedown', (e) => {
e.preventDefault();
sendBg('markStart').then((res) => {
if (res?.ok) applyFromState(res);
else setStatus('Start fehlgeschlagen — Leiste unten rechts auf der Seite nutzen');
updateButtons();
});
});
btnEnd?.addEventListener('mousedown', (e) => {
e.preventDefault();
sendBg('markEnd').then((res) => {
if (res?.ok) {
if (res.clip) clips.push(res.clip);
pendingStart = null;
applyFromState(res);
if (pageKey) saveClipDraft(pageKey, clips);
}
renderClips();
updateButtons();
});
});
btnCancelPending?.addEventListener('mousedown', (e) => {
e.preventDefault();
sendBg('clearPending').then(() => {
pendingStart = null;
updateButtons();
});
});
btnShowBar?.addEventListener('click', () => {
sendBg('showBar').then(() => setStatus('Leiste auf der Seite sollte sichtbar sein'));
});
btnQueueEach.addEventListener('click', () => sendQueue(false));
btnQueueMerge.addEventListener('click', () => sendQueue(true));
async function sendQueue(mergeClips) {
const state = await sendBg('getVideoState');
if (state?.pageUrl) pageUrl = state.pageUrl;
if (Array.isArray(state?.clips) && state.clips.length) {
clips = [...state.clips];
}
if (!pageUrl || !clips.length) {
setStatus('No clips — set start/end first');
return;
}
setStatus('Sende…');
const result = await chrome.runtime.sendMessage({
action: 'queueClips',
pageUrl,
clips,
mergeClips,
});
setStatus(result?.ok ? 'Queued.' : `Error: ${result?.error || '?'}`);
}
refresh();