// ==UserScript==
// @name FeedTube
// @description Userscript that renders YouTube channel RSS as a fast, full‑page feed — subscriptions, caching, source fallbacks, Shorts filter, playlists, and quick playback.
// @version 0.1
// @author TesterTV
// @homepageURL https://github.com/testertv/FeedTube
// @license GPL v.3 or any later version.
// @match file:///*FeedTube.html
// @match https://www.youtube.com/*
// @match https://m.youtube.com/*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @connect *
// @namespace https://greasyfork.org/ru/scripts/548703-feedtube
// ==/UserScript==
(() => {
'use strict';
// ===============================================
// ============ Code for FeedTube Page ===========
// ===============================================
if (location.protocol === 'file:' && /FeedTube\.html$/i.test(location.pathname)) {
// ============ PAGE META ============
document.title = "FeedTube";
const link = document.querySelector('link[rel~="icon"]') || document.head.appendChild(document.createElement('link'));
link.rel = 'icon';
link.href = 'https://www.youtube.com/favicon.ico';
// ============ CONFIG ============
const CLEAR_LOGS_KEY = 'clearLogs_v1';
const DEFAULT_CLEAR_LOGS = true;
const getClearLogs = () => getPref(CLEAR_LOGS_KEY, DEFAULT_CLEAR_LOGS) !== false;
const DEFAULT_LIMIT = 0;
const LIMIT_KEY = 'limit_v1';
const YT_NS = 'http://www.youtube.com/xml/schemas/2015';
const MEDIA_NS = 'http://search.yahoo.com/mrss/';
const SHOW_SHORTS_KEY = 'showShorts_v1';
const DEFAULT_SHOW_SHORTS = true;
const DEFAULT_SUBS = [
{ id: 'UCHnyfMqiRRG1u-2MsSQLbXA', name: 'Default Channel', enabled: true }
];
const SUBS_KEY = 'subs_v1';
const SRC_PREF_KEY = 'srcPref';
// Playlists
const PLAYLISTS_KEY = 'playlists_v1';
const TAB_PREF_KEY = 'tab_active_v1';
// ============ SHADOW UI ============
const host = document.createElement('div');
document.documentElement.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const css = document.createElement('style');
css.textContent = `
:host { all: initial; }
*, *::before, *::after { box-sizing: border-box; }
.app {
position: fixed; inset: 0; display: flex; flex-direction: column;
background: #0b0d10; color: #e6e9ef; font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
header {
position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 12px;
padding: 12px 16px; background: #0f1217; border-bottom: 1px solid #1f232b; flex-wrap: wrap;
}
.title { font-weight: 700; font-size: 16px; }
.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
select, button, input {
background: #2b313b; color: #e6e9ef; border: 1px solid #404652; border-radius: 6px; padding: 6px 10px;
}
button { cursor: pointer; }
button.primary { background: #1f6feb; color: #fff; border: none; }
.status { margin-left: auto; color: #9aa3b2; font-size: 12px; white-space: nowrap; }
.tabs {
display: flex; gap: 8px; padding: 10px 16px; background: #0f1217; border-bottom: 1px solid #1f232b;
}
.tab {
padding: 6px 12px; border-radius: 999px; border: 1px solid #2f3541; color: #cbd5e1; background: #1a1f28;
}
.tab.active { background: #1f6feb; border-color: #1f6feb; color: white; }
main { overflow: auto; padding: 16px; }
#videosView { flex: 1; }
#playlistsView { flex: 1; overflow: auto; padding: 16px; display: none; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.plGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.card { background: #11161c; border: 1px solid #1f232b; border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; }
.thumb { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #0b0d10; }
.thumb img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.meta { padding: 10px 12px; display: grid; gap: 6px; }
.meta a.title { color: #7aa2ff; text-decoration: none; font-weight: 700; }
.meta a.title:hover { text-decoration: underline; }
.muted { color: #9aa3b2; font-size: 12px; }
.error {
background: #2b1113; color: #ffd7d9; border: 1px solid #5a1a1f; padding: 12px; border-radius: 8px; white-space: pre-wrap;
}
.badge { display: inline-block; padding: 2px 6px; border-radius: 999px; font-size: 11px; margin-left: 6px; background: #2b313b; color: #cbd5e1; }
details.debug { padding: 0 16px 12px; }
details.debug > summary { cursor: pointer; color: #cbd5e1; }
pre {
background: #0f1217; color: #cbd5e1; padding: 10px; border-radius: 8px; border: 1px solid #1f232b; overflow: auto; max-height: 40vh;
}
footer { padding: 10px 16px; border-top: 1px solid #1f232b; color: #9aa3b2; background: #0f1217; }
/* Floating action buttons */
.fab {
position: fixed; right: 16px; bottom: 16px; z-index: 20; width: 52px; height: 52px;
border-radius: 50%; display: grid; place-items: center; background: #1f6feb; color: #fff;
border: none; box-shadow: 0 6px 20px rgba(0,0,0,.5); font-size: 20px;
}
.fab:hover { filter: brightness(1.05); }
.fab.left { right: 80px; }
/* Panels (subscriptions/settings/playlist picker) */
.subsPanel {
position: fixed; right: 16px; bottom: 80px; width: 480px; max-width: calc(100vw - 32px);
max-height: 70vh; display: none; flex-direction: column; overflow: hidden;
background: #11161c; border: 1px solid #1f232b; border-radius: 12px; z-index: 30;
box-shadow: 0 10px 30px rgba(0,0,0,.55);
}
.subsPanel.open { display: flex; }
.sp-head {
display: flex; align-items: center; justify-content: space-between; padding: 10px 12px;
background: #0f1217; border-bottom: 1px solid #1f232b;
}
.sp-title { font-weight: 700; }
.sp-body { display: grid; gap: 8px; padding: 10px 12px; overflow: auto; }
.subRow {
display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 8px;
padding: 6px 8px; border: 1px solid #1f232b; border-radius: 8px; background: #0b0d10;
}
.subName { font-weight: 600; color: #e6e9ef; }
.subSmall { color: #9aa3b2; font-size: 12px; }
.subActions { display: flex; align-items: center; gap: 6px; }
.sp-add { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px 12px; border-top: 1px solid #1f232b; background: #0f1217; }
.link { color: #7aa2ff; text-decoration: none; }
.link:hover { text-decoration: underline; }
/* Actions under thumbnails */
.actions {
display: flex; justify-content: center; gap: 8px; padding: 8px 12px 12px; flex-wrap: wrap;
}
.iconBtn {
background: #202531; border: 1px solid #2e3442; color: #e6e9ef; border-radius: 8px;
width: 36px; height: 36px; display: grid; place-items: center; cursor: pointer; transition: .15s ease;
}
.iconBtn:hover { background: #2b313b; border-color: #3a4352; transform: translateY(-1px); }
.iconBtn.disabled { opacity: .45; cursor: not-allowed; }
.iconBtn.success { background: #244c1a; border-color: #2b5f20; }
/* Playlists view */
.plTop {
display: grid; gap: 10px; margin-bottom: 10px;
}
.plList .subRow { grid-template-columns: 22px 1fr auto; }
.plList .subRow .subActions button { padding: 4px 8px; }
/* Floating overlay video panel */
.floatingPanel {
position: fixed; z-index: 61; width: 720px; height: 400px; min-width: 360px; min-height: 220px;
background: #0f1217; border: 1px solid #1f232b; border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,.7); display: none; resize: both; overflow: hidden;
}
.floatingPanel.open { display: block; }
.fpHead {
height: 40px; display:flex; align-items:center; justify-content: space-between; padding: 0 10px;
background: #11161c; border-bottom: 1px solid #1f232b; user-select: none; cursor: move;
}
.fpTitle { font-weight: 600; font-size: 14px; }
.fpClose { background:#e53935; color:#fff; border:0; border-radius: 6px; padding: 6px 10px; cursor:pointer; }
.fpBody { position: absolute; inset: 40px 0 0 0; }
.fpBody iframe { width: 100%; height: 100%; border: 0; }
`;
shadow.appendChild(css);
const app = document.createElement('div');
app.className = 'app';
app.innerHTML = `
<header>
<div class="title">Feed<span style="color:red;">Tube </span></div>
<div class="row">
<label>Source:</label>
<select id="sourceSel">
<option value="auto">Auto (direct → mirror → proxies)</option>
<option value="direct">Direct (GM_xhr → youtube.com)</option>
<option value="mirror">Mirror (r.jina.ai)</option>
<option value="proxy1">Proxy 1 (isomorphic-git)</option>
<option value="proxy2">Proxy 2 (allorigins)</option>
</select>
<button id="reloadBtn" class="primary">Reload</button>
</div>
<div class="status"></div>
</header>
<div class="tabs">
<button id="tabVideos" class="tab active">Videos</button>
<button id="tabPlaylists" class="tab">Playlists</button>
</div>
<main id="videosView"><div class="grid"></div></main>
<div id="playlistsView"></div>
<details class="debug"><summary>Debug info</summary><pre class="log"></pre></details>
<footer>If fetching is flaky on file://, disable blockers or serve via http://localhost to improve reliability.</footer>
<!-- Swapped positions: Settings is rightmost, Subscriptions is left -->
<button class="fab" id="settingsBtn" title="Settings">⚙️</button>
<button class="fab left" id="subsBtn" title="Manage subscriptions">🔔</button>
<div class="subsPanel" id="settingsPanel" aria-label="Settings">
<div class="sp-head">
<div class="sp-title">Settings</div>
<div>
<button id="st_close" title="Close" style="background:#e53935;color:#fff;border:0;padding:6px 10px;border-radius:4px;cursor:pointer">✕</button>
</div>
</div>
<div class="sp-body">
<div>
<input id="limitInput" type="number" min="0" step="1" placeholder="0 = all" />
<label for="limitInput">Max videos on main screen (0 = all)</label>
</div>
<div>
<label style="display:flex; align-items:center; gap:8px; cursor: pointer;">
<input id="showShortsChk" type="checkbox" />
Show Shorts
</label>
</div>
<div>
<label style="display:flex; align-items:center; gap:8px; cursor: pointer;">
<input id="clearLogsChk" type="checkbox" />
Clear Debug Log on Reload
</label>
</div>
</div>
<div class="sp-add">
<div class="subSmall">Press Enter to apply, or click Save.</div>
<button id="limitSaveBtn" class="primary">Save</button>
</div>
</div>
<div class="subsPanel" id="subsPanel" aria-label="Subscriptions">
<div class="sp-head">
<div class="sp-title">Subscriptions</div>
<div>
<div class="row">
<button id="enableAllBtn" title="Enable all subscriptions">☑</button>
<button id="disableAllBtn" title="Disable all subscriptions">☐</button>
<button id="invertSelBtn" title="Invert current selection">◪</button>
<button id="impExpBtn" title="Import or export subscriptions">Import / Export</button>
</div>
</div>
<div>
<button id="sp_close" title="Close" style="background:#e53935;color:#fff;border:0;padding:6px 10px;border-radius:4px;cursor:pointer">✕</button>
</div>
</div>
<div class="sp-body">
<div id="subsList"></div>
</div>
<div class="sp-add">
<input id="addInput" placeholder="Paste Channel-ID URL and click 'Add', or type to search…" />
<button id="addBtn" class="primary">Add</button>
</div>
</div>
<div class="subsPanel" id="iexPanel" aria-label="Import/Export">
<div class="sp-head">
<div class="sp-title">Import / Export (NewPipe Format)</div>
<div>
<button id="iex_close" title="Close" style="background:#e53935;color:#fff;border:0;padding:6px 10px;border-radius:4px;cursor:pointer">✕</button>
</div>
</div>
<div class="sp-body">
<input id="iex_file_merge" type="file" accept="application/json,.json" style="display:none" />
<input id="iex_file_replace" type="file" accept="application/json,.json" style="display:none" />
<div class="subRow">
<div style="width:22px;"></div>
<div>
<div class="subName">Import (add to existing)</div>
<div class="subSmall">Merge: keeps your current list and adds any new channels from the file.</div>
</div>
<div class="subActions">
<button id="iexImportMergeBtn">Choose file…</button>
</div>
</div>
<div class="subRow">
<div style="width:22px;"></div>
<div>
<div class="subName">Import (replace existing)</div>
<div class="subSmall">Replace: overwrites your current subscriptions with the file.</div>
</div>
<div class="subActions">
<button id="iexImportReplaceBtn">Choose file…</button>
</div>
</div>
<div class="subRow">
<div style="width:22px;"></div>
<div>
<div class="subName">Export</div>
<div class="subSmall">Download a NewPipe-compatible JSON of your subscriptions.</div>
</div>
<div class="subActions">
<button id="iexExportBtn">Export</button>
</div>
</div>
</div>
</div>
<!-- Playlist picker -->
<div class="subsPanel" id="plPicker" aria-label="Add to playlist">
<div class="sp-head">
<div class="sp-title">Add to playlist</div>
<div>
<button id="plp_close" title="Close" style="background:#e53935;color:#fff;border:0;padding:6px 10px;border-radius:4px;cursor:pointer">✕</button>
</div>
</div>
<div class="sp-body" id="plp_list"></div>
<div class="sp-add">
<input id="plp_new_name" placeholder="Create new playlist…" />
<button id="plp_create_add" class="primary">Create & Add</button>
</div>
</div>
<!-- Floating overlay video player -->
<div class="floatingPanel" id="floatPanel" role="dialog" aria-label="Video">
<div class="fpHead" id="fpHead">
<div class="fpTitle" id="fpTitle">Video</div>
<button class="fpClose" id="fpClose">✕</button>
</div>
<div class="fpBody">
<iframe id="fpFrame" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen></iframe>
</div>
</div>
`;
shadow.appendChild(app);
// ============ SHORTCUTS ============
const $ = s => shadow.querySelector(s);
const sourceSel = $('#sourceSel');
const reloadBtn = $('#reloadBtn');
const status = $('.status');
const videosView = $('#videosView');
const playlistsView = $('#playlistsView');
const grid = $('.grid');
const pre = $('.log');
const tabVideos = $('#tabVideos');
const tabPlaylists = $('#tabPlaylists');
const settingsBtn = $('#settingsBtn');
const settingsPanel = $('#settingsPanel');
const limitInput = $('#limitInput');
const showShortsChk = $('#showShortsChk');
const clearLogsChk = $('#clearLogsChk');
const limitSaveBtn = $('#limitSaveBtn');
const stClose = $('#st_close');
const enableAllBtn = $('#enableAllBtn');
const disableAllBtn = $('#disableAllBtn');
const invertSelBtn = $('#invertSelBtn');
const subsBtn = $('#subsBtn');
const subsPanel = $('#subsPanel');
const subsListEl = $('#subsList');
const addInput = $('#addInput');
const addBtn = $('#addBtn');
const spClose = $('#sp_close');
const floatPanel = $('#floatPanel');
const fpHead = $('#fpHead');
const fpTitle = $('#fpTitle');
const fpClose = $('#fpClose');
let fpFrame = $('#fpFrame');
// Playlist picker
const plPicker = $('#plPicker');
const plpList = $('#plp_list');
const plpClose = $('#plp_close');
const plpNewName = $('#plp_new_name');
const plpCreateAdd = $('#plp_create_add');
const embedUrl = id => `https://www.youtube.com/embed/${id}`;
// Import/Export
const impExpBtn = $('#impExpBtn');
const iexPanel = $('#iexPanel');
const iexClose = $('#iex_close');
const iexImportMergeBtn = $('#iexImportMergeBtn');
const iexImportReplaceBtn = $('#iexImportReplaceBtn');
const iexExportBtn = $('#iexExportBtn');
const iexFileMerge = $('#iex_file_merge');
const iexFileReplace = $('#iex_file_replace');
// ============ VIDEO OPENERS ============
function openEmbedNewTab(id) {
if (!id) return;
window.open(embedUrl(id) + '?autoplay=1', '_blank', 'noopener');
}
function openKioskPopup(id) {
if (!id) return;
const w = 720, h = 400;
const dualLeft = window.screenLeft !== undefined ? window.screenLeft : window.screenX;
const dualTop = window.screenTop !== undefined ? window.screenTop : window.screenY;
const vw = window.innerWidth || document.documentElement.clientWidth || screen.width;
const vh = window.innerHeight || document.documentElement.clientHeight || screen.height;
const left = Math.round(dualLeft + Math.max((vw - w) / 2, 0));
const top = Math.round(dualTop + Math.max((vh - h) / 2, 0));
const features = `popup=yes,noopener,noreferrer,toolbar=no,location=no,status=no,menubar=no,scrollbars=no,resizable=yes,width=${w},height=${h},left=${left},top=${top}`;
const win = window.open(embedUrl(id) + '?autoplay=1', '_blank', features);
try { win && win.focus(); } catch {}
}
function centerPanel(w = 720, h = 400) {
const vw = window.innerWidth, vh = window.innerHeight;
const left = Math.max((vw - w) / 2, 10);
const top = Math.max((vh - h) / 2, 10);
floatPanel.style.left = left + 'px';
floatPanel.style.top = top + 'px';
floatPanel.style.width = w + 'px';
floatPanel.style.height = h + 'px';
}
function loadVideoInPanel(id) {
const newFrame = document.createElement('iframe');
newFrame.id = 'fpFrame';
newFrame.allow = 'autoplay; encrypted-media; picture-in-picture';
newFrame.allowFullscreen = true;
newFrame.src = embedUrl(id) + '?autoplay=1';
fpFrame.replaceWith(newFrame);
fpFrame = newFrame;
}
function openOverlay(id, title) {
if (!id) return;
fpTitle.textContent = title || 'Video';
const wasOpen = floatPanel.classList.contains('open');
loadVideoInPanel(id);
floatPanel.classList.add('open');
if (!wasOpen) {
centerPanel(floatPanel.offsetWidth || 720, floatPanel.offsetHeight || 400);
}
}
function closeOverlay() {
floatPanel.classList.remove('open');
if (fpFrame) fpFrame.src = 'about:blank';
}
fpClose?.addEventListener('click', closeOverlay);
window.addEventListener('keydown', e => {
if (e.key === 'Escape' && floatPanel.classList.contains('open')) closeOverlay();
});
// Dragging the floating panel by its header
(function enablePanelDrag() {
let dragging = false, dx = 0, dy = 0;
fpHead?.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const rect = floatPanel.getBoundingClientRect();
dragging = true;
dx = e.clientX - rect.left;
dy = e.clientY - rect.top;
e.preventDefault();
});
window.addEventListener('mousemove', e => {
if (!dragging) return;
let left = e.clientX - dx;
let top = e.clientY - dy;
left = Math.max(0, Math.min(left, window.innerWidth - 80));
top = Math.max(0, Math.min(top, window.innerHeight - 60));
floatPanel.style.left = left + 'px';
floatPanel.style.top = top + 'px';
});
window.addEventListener('mouseup', () => (dragging = false));
window.addEventListener('resize', () => {
if (!floatPanel.classList.contains('open')) return;
const r = floatPanel.getBoundingClientRect();
const left = Math.min(Math.max(0, r.left), Math.max(0, window.innerWidth - r.width));
const top = Math.min(Math.max(0, r.top), Math.max(0, window.innerHeight - r.height));
floatPanel.style.left = left + 'px';
floatPanel.style.top = top + 'px';
});
})();
// ============ HELPERS ============
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
(document.body || document.documentElement).appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch {
return false;
}
}
}
function makeIconBtn(label, title, onClick, disabled) {
const b = document.createElement('button');
b.className = 'iconBtn' + (disabled ? ' disabled' : '');
b.textContent = label;
b.title = title;
if (!disabled) {
b.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
onClick && onClick(b);
});
}
return b;
}
// ============ PERSISTENCE ============
const getPref = (k, d) => { try { return GM_getValue(k, d); } catch { return d; } };
const setPref = (k, v) => { try { GM_setValue(k, v); } catch {} };
const getLimit = () => {
const v = Number(getPref(LIMIT_KEY, DEFAULT_LIMIT));
if (!Number.isFinite(v)) return DEFAULT_LIMIT;
return Math.max(Math.floor(v), 0);
};
const getShowShorts = () => getPref(SHOW_SHORTS_KEY, DEFAULT_SHOW_SHORTS) !== false;
let subs = sanitizeSubs(getPref(SUBS_KEY, DEFAULT_SUBS));
if (!Array.isArray(subs) || subs.length === 0) {
subs = DEFAULT_SUBS.slice();
setPref(SUBS_KEY, subs);
}
function sanitizeSubs(a) {
if (!Array.isArray(a)) return DEFAULT_SUBS.slice();
return a.map(x => ({
id: String(x.id || '').trim(),
name: String(x.name || '').trim() || 'Channel',
enabled: x.enabled !== false
})).filter(x => x.id);
}
function saveSubs() { setPref(SUBS_KEY, subs); }
// Playlists persistence
let playlists = sanitizePlaylists(getPref(PLAYLISTS_KEY, []));
function sanitizePlaylists(a) {
if (!Array.isArray(a)) return [];
return a.map(p => ({
id: String(p.id || ''),
name: String(p.name || '').trim() || 'Playlist',
created: Number(p.created || Date.now()),
videos: Array.isArray(p.videos) ? p.videos.filter(v => v && v.id) : []
})).filter(p => p.id);
}
function savePlaylists() { setPref(PLAYLISTS_KEY, playlists); }
function createPlaylist(name) {
const id = 'pl_' + Math.random().toString(36).slice(2, 10);
const p = { id, name: (name || 'Playlist').trim() || 'Playlist', created: Date.now(), videos: [] };
playlists.push(p);
savePlaylists();
return p;
}
function findPlaylist(id) { return playlists.find(p => p.id === id); }
function addVideoToPlaylist(pid, video) {
const p = findPlaylist(pid);
if (!p) return false;
if (p.videos.some(v => v.id === video.id)) return 'exists';
p.videos.unshift({
id: video.id,
href: video.href,
title: video.title,
thumb: video.thumb,
when: video.when || '',
author: video.author || '',
isShort: !!video.isShort,
addedAt: Date.now()
});
savePlaylists();
return true;
}
function removeVideoFromPlaylist(pid, vid) {
const p = findPlaylist(pid);
if (!p) return;
p.videos = p.videos.filter(v => v.id !== vid);
savePlaylists();
}
function renamePlaylist(pid, name) {
const p = findPlaylist(pid);
if (!p) return;
p.name = (name || 'Playlist').trim() || 'Playlist';
savePlaylists();
}
function deletePlaylist(pid) {
playlists = playlists.filter(p => p.id !== pid);
savePlaylists();
}
sourceSel.value = getPref(SRC_PREF_KEY, 'auto');
sourceSel.addEventListener('change', () => setPref(SRC_PREF_KEY, sourceSel.value));
// ============ LOGGING ============
const logs = [];
const log = (label, obj) => {
try { logs.push(label + ': ' + (typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2))); }
catch { logs.push(label + ': ' + String(obj)); }
pre.textContent = logs.join('\n\n');
};
// ============ UTILS ============
const bust = u => u + (u.includes('?') ? '&' : '?') + '_tm=' + Date.now();
const fmtDate = s => { const d = new Date(s); return isNaN(d) ? s : d.toLocaleString(); };
const nowIso = () => new Date().toLocaleString();
const feedUrl = id => `https://www.youtube.com/feeds/videos.xml?channel_id=${id}`;
const chUrl = id => `https://www.youtube.com/channel/${id}`;
function plural(n, unit) { return `${n} ${unit}${n === 1 ? '' : 's'} ago`; }
function timeAgo(iso) {
const d = new Date(iso);
if (isNaN(d)) return iso;
const now = new Date();
const diffMs = now - d;
if (diffMs < 0) return 'just now';
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return plural(mins, 'minute');
const hrs = Math.floor(mins / 60);
if (hrs < 24) return plural(hrs, 'hour');
const days = Math.floor(hrs / 24);
if (days < 30) return plural(days, 'day');
let months = (now.getFullYear() - d.getFullYear()) * 12 + (now.getMonth() - d.getMonth());
if (now.getDate() < d.getDate()) months--;
if (months < 12) return plural(Math.max(months, 1), 'month');
const years = Math.floor(months / 12);
return plural(years, 'year');
}
// ============ PER-CHANNEL CACHE ============
const txtKey = id => `yt_cache_text_${id}`;
const tsKey = id => `yt_cache_ts_${id}`;
function putCache(id, text) { try { GM_setValue(txtKey(id), text); GM_setValue(tsKey(id), Date.now()); } catch {} }
function getCache(id) {
try { return { text: GM_getValue(txtKey(id), ''), ts: GM_getValue(tsKey(id), 0) }; }
catch { return { text: '', ts: 0 }; }
}
// ============ NETWORK ============
function gmGet(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
anonymous: true,
headers: { Accept: 'application/atom+xml, application/rss+xml, application/xml;q=0.9, text/xml;q=0.8' },
timeout: 20000,
onload: r => (r.status >= 200 && r.status < 300)
? resolve(r.responseText)
: reject({ status: r.status, statusText: r.statusText, responseHeaders: r.responseHeaders }),
onerror: r => reject(r || { status: 0 }),
ontimeout: () => reject(new Error('Request timeout'))
});
});
}
async function fetchVia(url, label) {
log(`${label} -> start`, url);
const r = await fetch(url, { credentials: 'omit', cache: 'no-store' });
log(`${label} -> status`, `${r.status} ${r.statusText}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const txt = await r.text();
log(`${label} -> ok (length)`, txt.length);
return txt;
}
async function fetchDirect(url) {
const u = bust(url);
log('Direct GM_xhr -> start', u);
const txt = await gmGet(u);
log('Direct GM_xhr -> ok (length)', txt.length);
return txt;
}
async function fetchMirror(url) {
const u = 'https://r.jina.ai/http/' + url.replace(/^https?:\/\//, '') + '&_tm=' + Date.now();
return fetchVia(u, 'Mirror (r.jina.ai)');
}
async function fetchProxy1(url) {
const u = 'https://cors.isomorphic-git.org/' + url + '&_tm=' + Date.now();
return fetchVia(u, 'Proxy1 (isomorphic)');
}
async function fetchProxy2(url) {
const u = 'https://api.allorigins.win/raw?url=' + encodeURIComponent(url) + '&_tm=' + Date.now();
return fetchVia(u, 'Proxy2 (allorigins)');
}
async function getFeedText(url, sourceMode) {
const order = {
auto: [(u)=>fetchDirect(u), (u)=>fetchMirror(u), (u)=>fetchProxy1(u), (u)=>fetchProxy2(u)],
direct: [(u)=>fetchDirect(u)],
mirror: [(u)=>fetchMirror(u)],
proxy1: [(u)=>fetchProxy1(u)],
proxy2: [(u)=>fetchProxy2(u)],
}[sourceMode] || [(u)=>fetchDirect(u), (u)=>fetchMirror(u), (u)=>fetchProxy1(u), (u)=>fetchProxy2(u)];
for (let i = 0; i < order.length; i++) {
const fn = order[i];
for (let attempt = 1; attempt <= (i === 0 ? 2 : 1); attempt++) {
try { return await fn(url); }
catch (e) {
log(`Provider fail (#${i + 1}, attempt ${attempt})`, String(e && (e.message || e.status || e)));
if (attempt === 1 && i === 0) await new Promise(r => setTimeout(r, 800));
}
}
}
throw new Error('All providers failed.');
}
// ============ PARSER ============
function parseYouTube(xmlText) {
const xml = new DOMParser().parseFromString(xmlText, 'application/xml');
if (xml.querySelector('parsererror')) throw new Error('XML parse error');
const channelName = xml.getElementsByTagName('name')[0]?.textContent?.trim() || '';
const items = Array.from(xml.getElementsByTagName('entry')).map(entry => {
const title = (entry.getElementsByTagName('title')[0]?.textContent || '').trim();
let href = '';
for (const l of Array.from(entry.getElementsByTagName('link'))) {
const rel = l.getAttribute('rel'); const h = l.getAttribute('href');
if ((!rel || rel === 'alternate') && h) { href = h; break; }
}
const id = entry.getElementsByTagNameNS(YT_NS, 'videoId')[0]?.textContent?.trim() || '';
if (!href && id) href = `https://www.youtube.com/watch?v=${id}`;
const when = entry.getElementsByTagName('published')[0]?.textContent
|| entry.getElementsByTagName('updated')[0]?.textContent || '';
let thumb = entry.getElementsByTagNameNS(MEDIA_NS, 'thumbnail')[0]?.getAttribute('url') || '';
if (id) thumb = `https://i.ytimg.com/vi/${id}/mqdefault.jpg`;
const author = entry.getElementsByTagName('name')[0]?.textContent?.trim();
return { title, href, id, when, thumb, author };
}).filter(i => i.title && i.href);
return { items, channelName };
}
// ============ RENDER ============
function render(parsed, errorMsg, stats) {
const { items } = parsed || { items: [] };
const parts = [];
if (errorMsg) parts.push(`Error: ${errorMsg}`);
if (stats) {
const { enabled, ok, cached, failed, totalItems } = stats;
parts.push(`Channels: ${ok}${cached ? `+${cached} cached` : ''}${failed ? `, ${failed} failed` : ''} of ${enabled}`);
parts.push(`Items: ${totalItems}`);
}
parts.push(`Updated: ${nowIso()}`);
status.textContent = parts.join(' ');
if ((stats?.cached)) {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = 'cached';
status.appendChild(badge);
}
grid.textContent = '';
if (errorMsg && !items.length) {
const err = document.createElement('div');
err.className = 'error';
err.textContent = errorMsg;
grid.appendChild(err);
return;
}
for (const i of items) {
const card = document.createElement('div'); card.className = 'card';
const hasId = !!i.id;
const embedHref = hasId ? `https://www.youtube.com/embed/${i.id}` : i.href;
const youtubeUrl = i.href;
// Thumbnail
const a = document.createElement('a');
a.className = 'thumb';
a.href = embedHref;
const img = document.createElement('img'); img.src = i.thumb; img.alt = '';
a.appendChild(img);
// Actions under thumbnail
const actions = document.createElement('div');
actions.className = 'actions';
const b1 = makeIconBtn('↗', 'Open embedded player in new tab', () => openEmbedNewTab(i.id), !hasId);
const b2 = makeIconBtn('🪟', 'Open embedded player in popup (720×400)', () => openKioskPopup(i.id), !hasId);
const b3 = makeIconBtn('📺', 'Open embedded player in floating panel', () => openOverlay(i.id, i.title), !hasId);
const b4 = makeIconBtn('▶️', 'Open on YouTube', () => window.open(youtubeUrl, '_blank', 'noopener'), false);
const b5 = makeIconBtn('📋', 'Copy YouTube link', async (btn) => {
const ok = await copyToClipboard(youtubeUrl);
const old = btn.textContent;
btn.textContent = ok ? '✔' : '✖';
btn.classList.toggle('success', ok);
setTimeout(() => { btn.textContent = old; btn.classList.remove('success'); }, 1200);
}, false);
// New: Add to playlist button
const b6 = makeIconBtn('➕', 'Add to playlist', () => {
openPlaylistPicker({
id: i.id,
href: youtubeUrl,
title: i.title,
thumb: i.thumb,
when: i.when,
author: i.author,
isShort: !!i.isShort
});
}, !hasId);
actions.append(b1, b2, b3, b4, b5, b6);
// Meta
const meta = document.createElement('div'); meta.className = 'meta';
const t = document.createElement('a');
t.className = 'title';
t.href = embedHref;
t.textContent = i.title;
const rel = i.when ? timeAgo(i.when) : '';
const m = document.createElement('div');
m.className = 'muted';
m.textContent = `${i.author ? i.author + ' • ' : ''}${rel}`;
if (i.when) m.title = fmtDate(i.when);
if (i.isShort) {
const shortBadge = document.createElement('span');
shortBadge.className = 'badge';
shortBadge.textContent = 'Shorts';
m.appendChild(shortBadge);
}
meta.append(t, m);
// Order: thumb -> actions -> meta
card.append(a, actions, meta);
grid.appendChild(card);
}
}
// ============ AGGREGATION ============
function detectShortUsingOAR(videoId, timeoutMs = 8000) {
return new Promise(resolve => {
if (!videoId) return resolve(false);
const url = bust(`https://img.youtube.com/vi/${videoId}/oardefault.jpg`);
const img = new Image();
let done = false;
const finalize = (isShort) => { if (!done) { done = true; resolve(isShort); } };
const timer = setTimeout(() => finalize(false), timeoutMs);
img.onload = () => {
clearTimeout(timer);
const w = img.naturalWidth || img.width || 0;
const h = img.naturalHeight || img.height || 0;
const isShort = !(w === 120 && h === 90);
finalize(isShort);
};
img.onerror = () => { clearTimeout(timer); finalize(false); };
img.src = url;
});
}
async function markShorts(items) {
try {
const flags = await Promise.all(items.map(i => detectShortUsingOAR(i.id)));
return items.map((i, idx) => ({ ...i, isShort: !!flags[idx] }));
} catch (e) {
log('markShorts error', e && (e.message || String(e)));
return items.map(i => ({ ...i, isShort: false }));
}
}
async function fetchChannelFeed(ch, sourceMode) {
const url = feedUrl(ch.id);
try {
const text = await getFeedText(url, sourceMode);
const parsed = parseYouTube(text);
putCache(ch.id, text);
if (parsed.channelName && ch.name !== parsed.channelName) {
ch.name = parsed.channelName;
saveSubs();
renderSubsList();
}
const items = parsed.items.map(i => ({ ...i, author: parsed.channelName || i.author }));
return { ok: true, cached: false, items, channel: ch };
} catch (e) {
const c = getCache(ch.id);
if (c.text) {
try {
const parsed = parseYouTube(c.text);
const items = parsed.items.map(i => ({ ...i, author: parsed.channelName || i.author }));
return { ok: true, cached: true, items, channel: ch };
} catch {}
}
log(`Final fail for ${ch.id}`, e && (e.message || String(e)));
return { ok: false, cached: false, items: [], channel: ch, error: e && (e.message || String(e)) };
}
}
function sortDescByWhen(a, b) {
const ta = +new Date(a.when || 0);
const tb = +new Date(b.when || 0);
return tb - ta;
}
async function load() {
reloadBtn.disabled = true;
reloadBtn.textContent = 'Reloading...';
if (getClearLogs()) {
logs.length = 0;
pre.textContent = '';
} else {
log('Reload at', nowIso());
}
const enabledSubs = subs.filter(s => s.enabled !== false);
if (enabledSubs.length === 0) {
render({ items: [] }, 'No enabled subscriptions. Click the bell to add some.', { enabled: 0, ok: 0, cached: 0, failed: 0, totalItems: 0 });
reloadBtn.disabled = false;
reloadBtn.textContent = 'Reload';
subsPanel.classList.add('open');
addInput.focus();
return;
}
try {
const sourceMode = sourceSel.value;
const results = await Promise.allSettled(enabledSubs.map(ch => fetchChannelFeed(ch, sourceMode)));
let allItems = [];
let ok = 0, cached = 0, failed = 0;
for (const r of results) {
if (r.status === 'fulfilled' && r.value.ok) {
ok++;
if (r.value.cached) cached++;
allItems.push(...r.value.items);
} else {
failed++;
}
}
let itemsMarked = await markShorts(allItems);
const showShorts = getShowShorts();
if (!showShorts) itemsMarked = itemsMarked.filter(i => !i.isShort);
itemsMarked.sort(sortDescByWhen);
const limit = getLimit();
const cut = limit > 0 ? itemsMarked.slice(0, limit) : itemsMarked;
render({ items: cut }, null, {
enabled: enabledSubs.length, ok, cached, failed, totalItems: cut.length
});
} catch (e) {
const msg = e && (e.message || String(e));
log('Aggregate fail', msg);
render({ items: [] }, msg, null);
} finally {
reloadBtn.disabled = false;
reloadBtn.textContent = 'Reload';
}
}
reloadBtn.addEventListener('click', load);
// ============ SUBSCRIPTIONS PANEL ============
let subsFilter = '';
function renderSubsList() {
subsListEl.textContent = '';
const filterTxt = (subsFilter || '').trim().toLowerCase();
const list = filterTxt
? subs.filter(ch =>
(ch.name || '').toLowerCase().includes(filterTxt) ||
(ch.id || '').toLowerCase().includes(filterTxt)
)
: subs;
if (!list.length) {
const empty = document.createElement('div');
empty.className = 'subSmall';
empty.textContent = filterTxt
? 'No matches.'
: 'No subscriptions yet. Paste a channel URL, @handle, feed URL, or UC… ID below.';
subsListEl.appendChild(empty);
return;
}
for (const ch of list) {
const row = document.createElement('div');
row.className = 'subRow';
row.dataset.id = ch.id;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = ch.enabled !== false;
checkbox.title = 'Show on main screen';
checkbox.addEventListener('change', () => {
ch.enabled = checkbox.checked;
saveSubs();
load();
});
const nameBox = document.createElement('div');
const n1 = document.createElement('div'); n1.className = 'subName'; n1.textContent = ch.name || 'Channel';
const n2 = document.createElement('div'); n2.className = 'subSmall'; n2.textContent = ch.id;
nameBox.append(n1, n2);
const actions = document.createElement('div');
actions.className = 'subActions';
const openLink = document.createElement('a');
openLink.href = chUrl(ch.id);
openLink.target = '_blank'; openLink.rel = 'noopener';
openLink.className = 'link'; openLink.title = 'Open channel';
openLink.textContent = '↗';
const rm = document.createElement('button');
rm.title = 'Remove';
rm.textContent = '🗑️';
rm.addEventListener('click', () => {
subs = subs.filter(s => s.id !== ch.id);
saveSubs();
renderSubsList();
load();
});
actions.append(openLink, rm);
row.append(checkbox, nameBox, actions);
subsListEl.appendChild(row);
}
}
// Subscriptions panel toggles
impExpBtn?.addEventListener('click', () => {
if (iexPanel.classList.contains('open')) closeIex();
else openIex();
});
iexClose?.addEventListener('click', closeIex);
iexImportMergeBtn?.addEventListener('click', () => iexFileMerge?.click());
iexImportReplaceBtn?.addEventListener('click', () => iexFileReplace?.click());
iexFileMerge?.addEventListener('change', () => {
const f = iexFileMerge.files && iexFileMerge.files[0];
if (f) importFromNewPipeFile(f, 'merge');
});
iexFileReplace?.addEventListener('change', () => {
const f = iexFileReplace.files && iexFileReplace.files[0];
if (f) importFromNewPipeFile(f, 'replace');
});
iexExportBtn?.addEventListener('click', exportAsNewPipeJSON);
subsBtn.addEventListener('click', () => {
const willOpen = !subsPanel.classList.contains('open');
if (willOpen) {
subsPanel.classList.add('open');
settingsPanel.classList.remove('open');
iexPanel.classList.remove('open');
plPicker.classList.remove('open');
updateAddSearchUI();
addInput.focus();
} else {
closeSubsPanel(true);
}
});
spClose.addEventListener('click', () => closeSubsPanel(true));
addInput.addEventListener('input', () => {
const raw = (addInput.value || '').trim();
if (!isAddPattern(raw)) {
subsFilter = raw;
renderSubsList();
}
updateAddSearchUI();
});
addInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && isAddPattern(addInput.value)) {
addBtn.click();
}
});
addBtn.addEventListener('click', async () => {
const raw = (addInput.value || '').trim();
if (!raw) return;
if (!isAddPattern(raw)) {
resetSearch();
return;
}
addBtn.disabled = true; addBtn.textContent = 'Adding...';
try {
const id = await toChannelId(raw);
if (!id) throw new Error('Could not resolve to a channel ID');
if (subs.some(s => s.id === id)) {
subs = subs.map(s => s.id === id ? { ...s, enabled: true } : s);
saveSubs();
renderSubsList();
await load();
addInput.value = '';
updateAddSearchUI();
return;
}
const text = await getFeedText(feedUrl(id), sourceSel.value);
const parsed = parseYouTube(text);
const name = parsed.channelName || 'Channel';
subs.push({ id, name, enabled: true });
saveSubs();
putCache(id, text);
renderSubsList();
await load();
addInput.value = '';
updateAddSearchUI();
} catch (e) {
alert((e && e.message) ? e.message : 'Failed to add channel');
} finally {
addBtn.disabled = false; addBtn.textContent = 'Add';
}
});
enableAllBtn?.addEventListener('click', () => {
if (!subs.length) return;
subs = subs.map(s => ({ ...s, enabled: true }));
saveSubs();
renderSubsList();
load();
});
disableAllBtn?.addEventListener('click', () => {
if (!subs.length) return;
subs = subs.map(s => ({ ...s, enabled: false }));
saveSubs();
renderSubsList();
load();
});
invertSelBtn?.addEventListener('click', () => {
if (!subs.length) return;
subs = subs.map(s => ({ ...s, enabled: (s.enabled === false) })); // invert
saveSubs();
renderSubsList();
load();
});
// ============ SETTINGS ============
function openSettings() {
clearLogsChk.checked = getClearLogs();
const v = getPref(LIMIT_KEY, DEFAULT_LIMIT);
limitInput.value = String(Number.isFinite(+v) ? +v : DEFAULT_LIMIT);
showShortsChk.checked = getShowShorts();
settingsPanel.classList.add('open');
subsPanel.classList.remove('open');
iexPanel.classList.remove('open');
plPicker.classList.remove('open');
limitInput.focus();
limitInput.select();
}
function closeSettings() {
settingsPanel.classList.remove('open');
}
function applySettings() {
setPref(CLEAR_LOGS_KEY, !!clearLogsChk.checked);
let v = parseInt(limitInput.value, 10);
if (!Number.isFinite(v) || v < 0) v = 0;
setPref(LIMIT_KEY, v);
setPref(SHOW_SHORTS_KEY, !!showShortsChk.checked);
load();
}
settingsBtn.addEventListener('click', () => {
if (settingsPanel.classList.contains('open')) closeSettings();
else openSettings();
});
stClose.addEventListener('click', closeSettings);
limitSaveBtn.addEventListener('click', applySettings);
limitInput.addEventListener('keydown', e => {
if (e.key === 'Enter') applySettings();
});
showShortsChk.addEventListener('keydown', e => {
if (e.key === 'Enter') applySettings();
});
// ============ INPUT → CHANNEL ID RESOLUTION ============
const UC_RE = /^UC[0-9A-Za-z_-]{22}$/;
const FEED_CH_PARAM_RE = /[?&]channel_id=(UC[0-9A-Za-z_-]{22})/i;
async function toChannelId(input) {
const s = input.trim();
if (UC_RE.test(s)) return s;
const feedMatch = s.match(FEED_CH_PARAM_RE);
if (feedMatch) return feedMatch[1];
try {
let url = s;
if (!/^https?:\/\//i.test(url)) {
if (url.startsWith('@')) url = 'https://www.youtube.com/' + url;
else url = 'https://' + url;
}
const u = new URL(url);
const parts = u.pathname.split('/').filter(Boolean);
const chIdx = parts.indexOf('channel');
if (chIdx !== -1 && parts[chIdx + 1] && UC_RE.test(parts[chIdx + 1])) {
return parts[chIdx + 1];
}
const resolved = await resolveByMirror(u.href);
if (resolved) return resolved;
} catch {}
return null;
}
async function resolveByMirror(ytUrl) {
try {
const url = 'https://r.jina.ai/http/' + ytUrl.replace(/^https?:\/\//, '');
const html = await fetchVia(url, 'Resolve (r.jina.ai)');
const m = html.match(/"channelId":"(UC[0-9A-Za-z_-]{22})"/)
|| html.match(/"externalId":"(UC[0-9A-Za-z_-]{22})"/)
|| html.match(/data-channel-external-id="(UC[0-9A-Za-z_-]{22})"/);
return m ? m[1] : null;
} catch (e) {
log('resolveByMirror fail', e && (e.message || String(e)));
return null;
}
}
// ============ IMPORT/EXPORT ============
function openIex() {
iexPanel.classList.add('open');
subsPanel.classList.remove('open');
settingsPanel.classList.remove('open');
plPicker.classList.remove('open');
}
function closeIex() {
iexPanel.classList.remove('open');
}
function readFileText(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result || ''));
fr.onerror = () => reject(fr.error || new Error('File read error'));
fr.readAsText(file);
});
}
function extractUcFromUrl(url) {
if (!url) return null;
const m1 = String(url).match(/[?&]channel_id=(UC[0-9A-Za-z_-]{22})/i);
if (m1) return m1[1];
const m2 = String(url).match(/\/channel\/(UC[0-9A-Za-z_-]{22})(?:[/?#]|$)/i);
if (m2) return m2[1];
return null;
}
function parseNewPipeJSON(text) {
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error('Invalid JSON');
}
const arr = data && Array.isArray(data.subscriptions) ? data.subscriptions : null;
if (!arr) throw new Error('Invalid file: missing "subscriptions" array');
const out = [];
const seen = new Set();
const warnings = [];
for (const s of arr) {
if (!s || s.service_id !== 0) continue;
const id = extractUcFromUrl(s.url);
if (!id) {
warnings.push(`Skipped (no UC id): ${s.url || '(no url)'}`);
continue;
}
if (seen.has(id)) continue;
seen.add(id);
out.push({ id, name: String(s.name || '').trim() || 'Channel' });
}
return { list: out, warnings };
}
function exportAsNewPipeJSON() {
const data = {
app_version: '0.28.0',
app_version_int: 1005,
subscriptions: subs.map(s => ({
service_id: 0,
url: chUrl(s.id),
name: s.name || 'Channel'
}))
};
const text = JSON.stringify(data, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
const dateStr = new Date().toISOString().slice(0, 10);
a.download = `FeedTube_Subscriptions_${dateStr}.json`;
(document.body || document.documentElement).appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500);
}
async function importFromNewPipeFile(file, mode /* 'merge' | 'replace' */) {
try {
const text = await readFileText(file);
const { list, warnings } = parseNewPipeJSON(text);
if (!list.length) {
alert('No YouTube channels found in file.');
return;
}
let added = 0, updated = 0;
if (mode === 'replace') {
subs = list.map(({ id, name }) => ({ id, name, enabled: true }));
added = list.length;
} else {
const byId = new Map(subs.map(s => [s.id, s]));
for (const { id, name } of list) {
const ex = byId.get(id);
if (ex) {
if (name && ex.name !== name) { ex.name = name; updated++; }
} else {
subs.push({ id, name, enabled: true });
added++;
}
}
}
saveSubs();
renderSubsList();
await load();
const msg = `${mode === 'replace' ? 'Replaced' : 'Imported'}: ${added} added${updated ? `, ${updated} name(s) updated` : ''}` +
(warnings && warnings.length ? ` (skipped ${warnings.length})` : '');
log('Import result', msg);
if (warnings && warnings.length) log('Import warnings', warnings.join('\n'));
alert(msg);
closeIex();
} catch (e) {
const msg = e && (e.message || String(e));
log('Import failed', msg);
alert('Import failed: ' + msg);
} finally {
if (iexFileMerge) iexFileMerge.value = '';
if (iexFileReplace) iexFileReplace.value = '';
}
}
// ============ SEARCH (ADD/FILTER) ============
const isLikelyUrl = s => /^https?:\/\//i.test((s || '').trim());
const isAddPattern = s => {
s = (s || '').trim();
return !!s && (isLikelyUrl(s) || UC_RE.test(s) || FEED_CH_PARAM_RE.test(s) || s.startsWith('@'));
};
function updateAddSearchUI() {
const raw = (addInput.value || '').trim();
const addMode = isAddPattern(raw);
if (addMode || !raw) {
addBtn.textContent = 'Add';
addBtn.title = 'Add channel';
} else {
addBtn.textContent = 'Clear';
addBtn.title = 'Clear search filter';
}
}
function resetSearch() {
subsFilter = '';
addInput.value = '';
renderSubsList();
updateAddSearchUI();
}
function closeSubsPanel(reset = false) {
subsPanel.classList.remove('open');
if (reset) resetSearch();
}
// ============ TABS + PLAYLISTS UI ============
function showTab(which) {
if (which === 'playlists') {
videosView.style.display = 'none';
playlistsView.style.display = 'block';
tabPlaylists.classList.add('active');
tabVideos.classList.remove('active');
renderPlaylistsHome();
} else {
videosView.style.display = 'block';
playlistsView.style.display = 'none';
tabVideos.classList.add('active');
tabPlaylists.classList.remove('active');
}
setPref(TAB_PREF_KEY, which);
}
tabVideos?.addEventListener('click', () => showTab('videos'));
tabPlaylists?.addEventListener('click', () => showTab('playlists'));
function renderPlaylistsHome() {
playlistsView.textContent = '';
const top = document.createElement('div');
top.className = 'plTop';
const title = document.createElement('div');
title.className = 'subName';
title.textContent = 'Playlists';
const createRow = document.createElement('div');
createRow.className = 'row';
const inp = document.createElement('input');
inp.placeholder = 'New playlist name…';
const createBtn = document.createElement('button');
createBtn.className = 'primary';
createBtn.textContent = 'Create';
createBtn.addEventListener('click', () => {
const name = (inp.value || '').trim() || 'Playlist';
const p = createPlaylist(name);
inp.value = '';
renderPlaylistsHome();
// Optional: open it right away
// openPlaylistDetail(p.id);
});
createRow.append(inp, createBtn);
top.append(title, createRow);
playlistsView.appendChild(top);
const listWrap = document.createElement('div');
listWrap.className = 'plList';
if (!playlists.length) {
const empty = document.createElement('div');
empty.className = 'subSmall';
empty.textContent = 'No playlists yet. Add from the Videos tab, or create one above.';
listWrap.appendChild(empty);
} else {
for (const p of playlists) {
const row = document.createElement('div');
row.className = 'subRow';
const dot = document.createElement('div'); dot.style.width = '22px';
const nameBox = document.createElement('div');
const n1 = document.createElement('div'); n1.className = 'subName'; n1.textContent = p.name;
const n2 = document.createElement('div'); n2.className = 'subSmall'; n2.textContent = `${p.videos.length} video${p.videos.length === 1 ? '' : 's'}`;
nameBox.append(n1, n2);
const actions = document.createElement('div');
actions.className = 'subActions';
const openBtn = document.createElement('button');
openBtn.textContent = 'Open';
openBtn.addEventListener('click', () => openPlaylistDetail(p.id));
const renBtn = document.createElement('button');
renBtn.textContent = '✏️';
renBtn.title = 'Rename';
renBtn.addEventListener('click', () => {
const newName = prompt('Rename playlist:', p.name);
if (newName != null) {
renamePlaylist(p.id, newName);
renderPlaylistsHome();
}
});
const delBtn = document.createElement('button');
delBtn.textContent = '🗑️';
delBtn.title = 'Delete';
delBtn.addEventListener('click', () => {
if (confirm(`Delete playlist "${p.name}"?`)) {
deletePlaylist(p.id);
renderPlaylistsHome();
}
});
actions.append(openBtn, renBtn, delBtn);
row.append(dot, nameBox, actions);
listWrap.appendChild(row);
}
}
playlistsView.appendChild(listWrap);
}
function openPlaylistDetail(pid) {
const p = findPlaylist(pid);
if (!p) { renderPlaylistsHome(); return; }
playlistsView.textContent = '';
const head = document.createElement('div');
head.className = 'row';
const back = document.createElement('button');
back.textContent = '← Back';
back.addEventListener('click', renderPlaylistsHome);
const title = document.createElement('div');
title.className = 'subName';
title.textContent = p.name;
title.style.marginLeft = '8px';
const actions = document.createElement('div');
actions.className = 'row';
actions.style.marginLeft = 'auto';
const renBtn = document.createElement('button');
renBtn.textContent = '✏️ Rename';
renBtn.addEventListener('click', () => {
const newName = prompt('Rename playlist:', p.name);
if (newName != null) { renamePlaylist(p.id, newName); openPlaylistDetail(p.id); }
});
const delBtn = document.createElement('button');
delBtn.textContent = '🗑️ Delete';
delBtn.addEventListener('click', () => {
if (confirm(`Delete playlist "${p.name}"?`)) {
deletePlaylist(p.id);
renderPlaylistsHome();
}
});
actions.append(renBtn, delBtn);
head.append(back, title, actions);
playlistsView.appendChild(head);
const grid = document.createElement('div');
grid.className = 'plGrid';
if (!p.videos.length) {
const empty = document.createElement('div');
empty.className = 'subSmall';
empty.textContent = 'No videos here yet.';
playlistsView.appendChild(empty);
} else {
for (const v of p.videos) {
const card = document.createElement('div'); card.className = 'card';
const hasId = !!v.id;
const embedHref = hasId ? `https://www.youtube.com/embed/${v.id}` : v.href;
const youtubeUrl = v.href;
// Thumb
const a = document.createElement('a');
a.className = 'thumb';
a.href = embedHref;
const img = document.createElement('img'); img.src = v.thumb; img.alt = '';
a.appendChild(img);
// Actions
const actions = document.createElement('div');
actions.className = 'actions';
const b1 = makeIconBtn('↗', 'Open embedded player in new tab', () => openEmbedNewTab(v.id), !hasId);
const b2 = makeIconBtn('🪟', 'Open embedded player in popup (720×400)', () => openKioskPopup(v.id), !hasId);
const b3 = makeIconBtn('📺', 'Open embedded player in floating panel', () => openOverlay(v.id, v.title), !hasId);
const b4 = makeIconBtn('▶️', 'Open on YouTube', () => window.open(youtubeUrl, '_blank', 'noopener'), false);
const b5 = makeIconBtn('📋', 'Copy YouTube link', async (btn) => {
const ok = await copyToClipboard(youtubeUrl);
const old = btn.textContent;
btn.textContent = ok ? '✔' : '✖';
btn.classList.toggle('success', ok);
setTimeout(() => { btn.textContent = old; btn.classList.remove('success'); }, 1200);
}, false);
const b6 = makeIconBtn('➕', 'Add to another playlist', () => {
openPlaylistPicker({
id: v.id,
href: youtubeUrl,
title: v.title,
thumb: v.thumb,
when: v.when,
author: v.author,
isShort: !!v.isShort
});
}, !hasId);
const b7 = makeIconBtn('🗑️', 'Remove from this playlist', () => {
removeVideoFromPlaylist(p.id, v.id);
openPlaylistDetail(p.id);
}, false);
actions.append(b1, b2, b3, b4, b5, b6, b7);
// Meta
const meta = document.createElement('div'); meta.className = 'meta';
const t = document.createElement('a');
t.className = 'title';
t.href = embedHref;
t.textContent = v.title || '(no title)';
const rel = v.when ? timeAgo(v.when) : '';
const m = document.createElement('div');
m.className = 'muted';
m.textContent = `${v.author ? v.author + ' • ' : ''}${rel}`;
if (v.when) m.title = fmtDate(v.when);
if (v.isShort) {
const shortBadge = document.createElement('span');
shortBadge.className = 'badge';
shortBadge.textContent = 'Shorts';
m.appendChild(shortBadge);
}
meta.append(t, m);
card.append(a, actions, meta);
grid.appendChild(card);
}
}
playlistsView.appendChild(grid);
}
// ============ PLAYLIST PICKER ============
let pickerVideo = null;
function openPlaylistPicker(video) {
pickerVideo = video;
renderPlaylistPicker();
plPicker.classList.add('open');
settingsPanel.classList.remove('open');
subsPanel.classList.remove('open');
iexPanel.classList.remove('open');
plpNewName.value = '';
plpNewName.placeholder = 'Create new playlist…';
}
function closePlaylistPicker() {
pickerVideo = null;
plPicker.classList.remove('open');
}
function renderPlaylistPicker() {
plpList.textContent = '';
if (!playlists.length) {
const empty = document.createElement('div');
empty.className = 'subSmall';
empty.textContent = 'No playlists yet. Create one below.';
plpList.appendChild(empty);
return;
}
for (const p of playlists) {
const row = document.createElement('div');
row.className = 'subRow';
const dot = document.createElement('div'); dot.style.width = '22px';
const box = document.createElement('div');
const n1 = document.createElement('div'); n1.className = 'subName'; n1.textContent = p.name;
const n2 = document.createElement('div'); n2.className = 'subSmall'; n2.textContent = `${p.videos.length} video${p.videos.length === 1 ? '' : 's'}`;
box.append(n1, n2);
const actions = document.createElement('div');
actions.className = 'subActions';
const addBtn = document.createElement('button');
addBtn.textContent = 'Add';
addBtn.addEventListener('click', () => {
if (!pickerVideo) return;
const res = addVideoToPlaylist(p.id, pickerVideo);
if (res === 'exists') {
addBtn.textContent = 'Already added';
setTimeout(() => { addBtn.textContent = 'Add'; }, 1000);
} else {
addBtn.textContent = '✔ Added';
setTimeout(() => {
addBtn.textContent = 'Add';
closePlaylistPicker();
if (tabPlaylists.classList.contains('active')) renderPlaylistsHome();
}, 700);
}
});
actions.append(addBtn);
row.append(dot, box, actions);
plpList.appendChild(row);
}
}
plpClose?.addEventListener('click', closePlaylistPicker);
plpCreateAdd?.addEventListener('click', () => {
if (!pickerVideo) return;
const name = (plpNewName.value || '').trim() || 'Playlist';
const p = createPlaylist(name);
addVideoToPlaylist(p.id, pickerVideo);
plpNewName.value = '';
closePlaylistPicker();
if (tabPlaylists.classList.contains('active')) renderPlaylistsHome();
});
plpNewName?.addEventListener('keydown', e => {
if (e.key === 'Enter') plpCreateAdd?.click();
});
// ============ LIVE SYNC SUBSCRIPTIONS ============
(function setupLiveSync() {
let lastJSON = '';
try { lastJSON = JSON.stringify(subs); } catch { lastJSON = '[]'; }
// If the script manager supports events, we catch instant changes.
try {
if (typeof GM_addValueChangeListener === 'function') {
GM_addValueChangeListener(SUBS_KEY, (name, oldVal, newVal, remote) => {
if (!remote) return; // ignore our own records
const prevIds = new Set((subs || []).map(s => s.id));
subs = sanitizeSubs(newVal);
lastJSON = JSON.stringify(subs);
renderSubsList();
load();
// Small highlight for new lines in the open panel (optional)
if (subsPanel?.classList?.contains('open')) {
for (const s of subs) {
if (!prevIds.has(s.id)) {
const row = subsListEl.querySelector(`.subRow[data-id="${s.id}"]`);
if (row) {
row.style.outline = '2px solid #1f6feb';
setTimeout(() => (row.style.outline = ''), 1200);
}
}
}
}
});
}
} catch {}
// Alternative option: check every 2 seconds
setInterval(() => {
const latest = sanitizeSubs(getPref(SUBS_KEY, []));
const j = JSON.stringify(latest);
if (j !== lastJSON) {
subs = latest;
lastJSON = j;
renderSubsList();
load();
}
}, 2000);
})();
// ============ INIT ============
renderSubsList();
showTab(getPref(TAB_PREF_KEY, 'videos'));
load();
}
// ===============================================
// ============ Code for Youtube Page ============
// ===============================================
if (/(^|\.)youtube\.com$/i.test(location.hostname)) {
// Access the "real" window object to get YouTube's page data
const UW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : (window.wrappedJSObject || window);
const BTN_ID = 'feedtube-add-channel-button';
const BTN_STYLE = `
padding: 0 16px;
height: 36px;
background-color: #065fd4; /* YouTube blue */
color: white;
border: none;
border-radius: 18px;
cursor: pointer;
font-family: Roboto, Arial, sans-serif;
font-size: 14px;
font-weight: 500;
display: inline-flex;
align-items: center;
vertical-align: top;
margin-left: 8px; /* Add some space */
flex-shrink: 0; /* Prevent button from shrinking in flex containers */
`;
// Constants to match your FeedTube.html script
const SUBS_KEY = 'subs_v1';
const UC_RE = /^UC[0-9A-Za-z_-]{22}$/;
// Helper functions for getting/setting script data
const getPref = (k, d) => { try { return GM_getValue(k, d); } catch { return d; } };
const setPref = (k, v) => { try { GM_setValue(k, v); } catch {} };
function saveChannelToSubs(id, name) {
if (!UC_RE.test(id)) {
console.error('FeedTube: Invalid channel ID format:', id);
return 'error';
}
let subs = getPref(SUBS_KEY, []);
if (!Array.isArray(subs)) subs = [];
const existingSub = subs.find(s => s && s.id === id);
if (existingSub) {
existingSub.enabled = true;
if (name && (!existingSub.name || existingSub.name === 'Default Channel' || existingSub.name === 'Channel')) {
existingSub.name = name;
}
setPref(SUBS_KEY, subs);
return 'enabled';
} else {
subs.push({ id, name: name || 'Channel', enabled: true });
setPref(SUBS_KEY, subs);
return 'added';
}
}
// ---- DATA EXTRACTION LOGIC ----
function extractChannelData() {
let channelId = null;
let channelName = null;
const path = location.pathname;
channelId = UW?.ytInitialData?.metadata?.channelMetadataRenderer?.externalId ||
document.querySelector('meta[itemprop="channelId"]')?.content ||
null;
if (!channelId && path.startsWith('/channel/')) {
channelId = path.split('/')[2];
}
if (path.startsWith('/watch')) {
const ownerRenderer = UW?.ytInitialData?.contents?.twoColumnWatchNextResults?.results?.results?.contents
?.find(c => c?.videoSecondaryInfoRenderer?.owner)?.videoSecondaryInfoRenderer?.owner?.videoOwnerRenderer;
if (ownerRenderer) {
channelId = ownerRenderer.navigationEndpoint?.browseEndpoint?.browseId || channelId;
channelName = ownerRenderer.title?.simpleText || null;
}
}
if (!channelName) {
channelName = UW?.ytInitialData?.metadata?.channelMetadataRenderer?.title ||
document.querySelector('meta[property="og:title"]')?.content?.replace(/ - YouTube$/, '') ||
'Channel';
}
return {
id: UC_RE.test(channelId || '') ? channelId : null,
name: channelName.trim()
};
}
// ---- BUTTON ACTIONS ----
function addCurrentChannel(btn) {
const { id, name } = extractChannelData();
if (!id) {
alert('FeedTube: Could not identify the YouTube channel ID on this page.');
return;
}
const result = saveChannelToSubs(id, name);
flashBtn(btn, result);
}
function flashBtn(btn, result) {
if (!btn || result === 'error') return;
const oldText = btn.textContent;
const oldBg = btn.style.backgroundColor;
btn.textContent = result === 'added' ? '✓ Added' : '✓ Enabled';
btn.style.backgroundColor = '#2e7d32'; // Green
setTimeout(() => {
btn.textContent = oldText;
btn.style.backgroundColor = oldBg;
}, 1500);
}
function createButton() {
const button = document.createElement('button');
button.id = BTN_ID;
button.textContent = 'Add to FeedTube';
button.style.cssText = BTN_STYLE;
button.addEventListener('click', () => addCurrentChannel(button));
button.addEventListener('mouseenter', () => (button.style.filter = 'brightness(1.1)'));
button.addEventListener('mouseleave', () => (button.style.filter = ''));
return button;
}
// ---- MODIFIED BUTTON INSERTION LOGIC ----
let insertionInterval = null;
function attemptToAddButton() {
// If button already exists, we're done
if (document.getElementById(BTN_ID)) {
return true;
}
// THIS IS THE KEY CHANGE.
// Instead of finding a container to append to, we find a specific
// element to insert our button AFTER. This ensures it stays on the same line.
const referencePoints = [
// 1. Video Page (/watch?v=...)
() => document.querySelector('#subscribe-button.style-scope.ytd-watch-metadata'),
// 2. Modern Channel Page (/@handle) - Find the subscribe button's renderer
() => document.querySelector('ytd-c4-tabbed-header-renderer ytd-subscribe-button-renderer'),
// 3. Legacy Channel Page (fallback from your first script)
() => document.querySelector('.ytSpecButtonViewModelHost')?.parentElement
];
for (const findReference of referencePoints) {
const ref = findReference();
if (ref) {
// Use the modern `.after()` method to insert the button
// immediately after the reference element.
ref.after(createButton());
return true; // Success!
}
}
return false; // Failed to find any location
}
function ensureButtonIsAdded() {
if (insertionInterval) clearInterval(insertionInterval);
let attempts = 0;
const maxAttempts = 30;
insertionInterval = setInterval(() => {
attempts++;
const success = attemptToAddButton();
if (success || attempts >= maxAttempts) {
clearInterval(insertionInterval);
insertionInterval = null;
}
}, 250);
}
// --- SCRIPT EXECUTION ---
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensureButtonIsAdded);
} else {
ensureButtonIsAdded();
}
window.addEventListener('yt-navigate-finish', ensureButtonIsAdded);
// Observer is less critical now but good as a backup
const observer = new MutationObserver(() => {
// A simple check to avoid running the interval function constantly
if (!document.getElementById(BTN_ID)) {
ensureButtonIsAdded();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
})();