Cytu.be Playlist Manager

Google Drive–based playlist manager for Cytu.be. Saves multiple playlists per channel and lets you append, replace, or add random videos with auto-dedupe, import/export, and a sleek glass-style UI.

// ==UserScript==
// @name         Cytu.be Playlist Manager
// @namespace    cytube-saved-playlists
// @version      3.6.0
// @description  Google Drive–based playlist manager for Cytu.be. Saves multiple playlists per channel and lets you append, replace, or add random videos with auto-dedupe, import/export, and a sleek glass-style UI.
// @match        https://cytu.be/r/*
// @match        https://*.cytu.be/r/*
// @match        http://cytu.be/r/*
// @match        http://*.cytu.be/r/*
// @grant        GM_xmlhttpRequest
// @connect      script.google.com
// @connect      googleusercontent.com
// @connect      google.com
// @connect      *
// @run-at       document-idle
// ==/UserScript==

(function(){
  'use strict';
  const $=(s,r=document)=>r.querySelector(s);
  const $$=(s,r=document)=>Array.from(r.querySelectorAll(s));
  const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
  const channelName=(window.CHANNEL&&CHANNEL.name)||location.pathname.split('/').filter(Boolean).pop()||'default';

  // ===== CONFIG =====
  const DEFAULT_PULL_MS = 10000; // background pull interval; change here
  const MIN_PULL_MS     = 3000;  // hard floor
  // ==================

  // keys
  const KEY=(n)=>`ct_savedpl:${channelName}:${n}`;
  const LISTKEY=`ct_savedpl_index:${channelName}`;
  const UIKEY_MAIN=`ct_ui_collapsed_main:${channelName}`;
  const UIKEY_DB=`ct_ui_collapsed_db:${channelName}`;
  const AUTOPUSH=`ct_autopush:${channelName}`;
  const AUTOPULL=`ct_autopull:${channelName}`;
  const AUTOPULL_MS_KEY=`ct_autopull_ms:${channelName}`;
  const REV_BROADCAST_KEY=`ct_db_rev:${channelName}`;

  // toast
  function toast(msg){
    try{
      const t=document.createElement('div');
      t.textContent=String(msg);
      t.style.cssText='position:fixed;right:12px;bottom:12px;z-index:999999;background:rgba(20,20,20,.92);color:#fff;padding:8px 10px;border-radius:12px;font:12px/1.2 system-ui;max-width:62ch;box-shadow:0 10px 30px rgba(0,0,0,.35);white-space:pre-wrap;backdrop-filter:blur(12px) saturate(140%);border:1px solid rgba(255,255,255,.12)';
      document.body.appendChild(t); setTimeout(()=>t.remove(),4200);
    }catch{}
  }

  // GM fetch
  function gmFetch(url,{method='GET',headers={},data=null,responseType='text'}={}){
    return new Promise((resolve,reject)=>{
      GM_xmlhttpRequest({url,method,headers,data,responseType,onload:r=>resolve(r),onerror:e=>reject(e),ontimeout:()=>reject(new Error('GM timeout'))});
    });
  }
  async function gmFetchJSON(url,opts={}){
    const r=await gmFetch(url,{...opts,responseType:'text'});
    const text=r.responseText||'';
    try{ return { json: JSON.parse(text), status:r.status, text }; }
    catch{ return { json: null, status:r.status, text }; }
  }

  // local store
  const getIndexLocal=()=>{try{return JSON.parse(localStorage.getItem(LISTKEY)||'[]')}catch{return[]}};
  const setIndexLocal=(arr)=>{const uniq=[...new Set(arr.filter(Boolean))].sort((a,b)=>a.localeCompare(b)); localStorage.setItem(LISTKEY,JSON.stringify(uniq)); return uniq;};
  const readSavedLocal=(name)=>{try{const raw=localStorage.getItem(KEY(name)); return raw?JSON.parse(raw):{savedAt:0,items:[]}}catch{return{savedAt:0,items:[]}}};
  const writeSavedLocal=(name,payload)=>localStorage.setItem(KEY(name),JSON.stringify(payload));

  // url helpers
  function parseYouTubeId(u){try{const url=new URL(u); if(url.hostname==='youtu.be')return url.pathname.slice(1); if(url.hostname.endsWith('youtube.com')){ if(url.pathname==='/watch')return url.searchParams.get('v')||''; if(url.pathname.startsWith('/shorts/'))return url.pathname.split('/')[2]||''; const m=url.pathname.match(/^\/embed\/([^\/?#]+)/); if(m) return m[1]; }}catch{} const m=String(u).match(/^[A-Za-z0-9_-]{8,}$/); return m?m[0]:'';}
  const parseVimeoId=(u)=>{try{const m=String(u).match(/vimeo\.com\/(\d+)/i); return m?m[1]:''}catch{return''}};
  const parseDailymotionId=(u)=>{try{const m=String(u).match(/dailymotion\.com\/video\/([a-z0-9]+)/i); return m?m[1]:''}catch{return''}};
  const normalizeOtherUrl=(u)=>{try{const url=new URL(u); url.search=''; url.hash=''; return url.origin+url.pathname;}catch{return String(u).trim();}};
  const urlToKey=(u)=>{const yt=parseYouTubeId(u); if(yt)return`yt:${yt}`; const vi=parseVimeoId(u); if(vi)return`vi:${vi}`; const dm=parseDailymotionId(u); if(dm)return`dm:${dm}`; return`url:${normalizeOtherUrl(u)}`;};

  const scrapeCurrentQueueKeys=()=>{const keys=[]; for(const el of $$('#queue .queue_entry, .queue_item')){ const svc=(el.dataset?.service||el.getAttribute('data-service')||'').toLowerCase(); const id=el.dataset?.id||el.getAttribute('data-id')||''; if(svc&&id){ if(svc==='yt')keys.push(`yt:${id}`); else if(svc==='vi'||svc==='vimeo')keys.push(`vi:${id}`); else if(svc==='dm')keys.push(`dm:${id}`); else keys.push(`url:${svc}:${id}`); continue;} const a=el.querySelector('a[href]'); if(a&&a.href) keys.push(urlToKey(a.href)); } if(!keys.length){ for(const a of $$('#queue a, #playlist a, .queue a, .qe_title a')){ if(a.href) keys.push(urlToKey(a.href)); } } return keys; };
  const getCurrentKeySet=()=>new Set(scrapeCurrentQueueKeys());

  async function clearPlaylistRobust(){
    try{ if(window.socket&&typeof window.socket.emit==='function'){ window.socket.emit('playlistClear'); for(let i=0;i<15;i++){ if(($$('#queue .queue_entry').length||0)===0) return true; await sleep(100);} } }catch{}
    const clearBtn=$('#btn-clearplaylist')||$$('button, a').find(b=>/clear/i.test(b.textContent||'')||/clear/i.test(b.getAttribute?.('title')||''));
    if(clearBtn){ clearBtn.click(); await sleep(80); const confirmBtn=$$('button, a').find(b=>/confirm|yes|ok/i.test(b.textContent||'')&&b.offsetParent); if(confirmBtn)confirmBtn.click(); for(let i=0;i<80;i++){ if(($$('#queue .queue_entry').length||0)===0) return true; await sleep(100);} }
    return false;
  }

  // remote DB
  const DBSTATE={connected:false,baseUrl:'',dbId:'',token:'',cache:null};
  const saveConnPerChannel=()=>localStorage.setItem(`ct_db_conn:${channelName}`, JSON.stringify({baseUrl:DBSTATE.baseUrl,dbId:DBSTATE.dbId,token:DBSTATE.token}));
  const loadConnPerChannel=()=>{try{const raw=localStorage.getItem(`ct_db_conn:${channelName}`); if(!raw) return false; const {baseUrl,dbId,token}=JSON.parse(raw); if(baseUrl&&dbId){ DBSTATE.baseUrl=baseUrl; DBSTATE.dbId=dbId; DBSTATE.token=token||''; DBSTATE.connected=true; return true; }}catch{} return false; };

  async function driveInit(baseUrl){
    const initPost=baseUrl.replace(/\?.*$/,'')+'?init=1';
    let r=await gmFetchJSON(initPost,{method:'POST',headers:{'Content-Type':'application/json'},data:JSON.stringify({ping:1})});
    if(!r.json||!r.json.dbId){ const initGet=baseUrl.replace(/\?.*$/,'')+'?init=1'; r=await gmFetchJSON(initGet,{method:'GET'}); if(!r.json||!r.json.dbId){ toast(`Create failed. Status ${r.status}`); throw new Error('init failed'); } }
    return r.json.dbId;
  }
  async function drivePull(){
    const url=`${DBSTATE.baseUrl}?db=${encodeURIComponent(DBSTATE.dbId)}`;
    const r=await gmFetchJSON(url,{method:'GET'});
    if(!r.json||r.json.error){ toast(`Pull failed (${r.status})`); throw new Error('pull failed'); }
    DBSTATE.cache=r.json; if(!DBSTATE.cache.playlists) DBSTATE.cache.playlists={}; return DBSTATE.cache;
  }
  async function drivePush(){
    if(!DBSTATE.cache) throw new Error('nothing to push');
    const url=`${DBSTATE.baseUrl}?db=${encodeURIComponent(DBSTATE.dbId)}&op=put${DBSTATE.token?`&token=${encodeURIComponent(DBSTATE.token)}`:''}`;
    const r=await gmFetchJSON(url,{method:'POST',headers:{'Content-Type':'application/json'},data:JSON.stringify(DBSTATE.cache)});
    if(!r.json){ toast(`Push failed (${r.status})`); throw new Error('push failed'); }
    if(r.json.error==='unauthorized') throw new Error('unauthorized (token?)');
    if(r.json.error==='conflict'&&r.json.server){ DBSTATE.cache=mergeServer_(DBSTATE.cache,r.json.server); const r2=await gmFetchJSON(url,{method:'POST',headers:{'Content-Type':'application/json'},data:JSON.stringify(DBSTATE.cache)}); if(!r2.json||r2.json.error) throw new Error('push after merge failed'); try{ localStorage.setItem(REV_BROADCAST_KEY, String(Date.now())); }catch{} return true; }
    if(r.json.error) throw new Error('push failed: '+r.json.error);
    try{ localStorage.setItem(REV_BROADCAST_KEY, String(Date.now())); }catch{}
    return true;
  }
  const mergeServer_=(local,server)=>{ const out=JSON.parse(JSON.stringify(server)); out.playlists=out.playlists||{}; const L=local.playlists||{}; for(const [name,pl] of Object.entries(L)){ const s=out.playlists[name]; if(!s){ out.playlists[name]=pl; continue; } const seen=new Set((s.items||[]).map(urlToKey)); const add=(pl.items||[]).filter(u=>!seen.has(urlToKey(u))); if(add.length) s.items=(s.items||[]).concat(add); s.savedAt=Math.max(+s.savedAt||0,+pl.savedAt||0); } out.version=server.version||local.version||1; out.sync=server.sync||{}; return out; };
  const usingRemote=()=>DBSTATE.connected&&DBSTATE.cache;

  // storage facade
  const _readSaved=readSavedLocal,_writeSavedLocal=writeSavedLocal,_getIndex=getIndexLocal,_setIndex=setIndexLocal;
  function readSaved(n){ return usingRemote()? (DBSTATE.cache.playlists[n]||{savedAt:0,items:[]}) : _readSaved(n); }
  function writeSaved(n,p){ if(!usingRemote()) return _writeSavedLocal(n,p); DBSTATE.cache.playlists[n]={savedAt:p.savedAt||Date.now(),items:p.items||[]}; maybeAutoPush(); }
  function getIndex(){ return usingRemote()? Object.keys(DBSTATE.cache.playlists).sort((a,b)=>a.localeCompare(b)) : _getIndex(); }
  function setIndex(arr){ if(!usingRemote()) return _setIndex(arr); const want=new Set(arr.filter(Boolean)); for(const k of Object.keys(DBSTATE.cache.playlists)) if(!want.has(k)) delete DBSTATE.cache.playlists[k]; maybeAutoPush(); return [...want].sort((a,b)=>a.localeCompare(b)); }

  // actions
  function saveAs(name){ if(!name){toast('Enter a name');return} const urls=scrapeUrlsFromQueue(); if(!urls.length){toast('Playlist is empty');return} writeSaved(name,{savedAt:Date.now(),items:urls}); const idx=setIndex([...getIndex(),name]); refreshList(idx,name); toast(`Saved “${name}” (${urls.length})`); }

  async function loadWhole(name,mode){ if(!name){toast('Pick a name');return} const {items=[]}=readSaved(name); const input=$('#mediaurl')||$('input#mediaurl')||$$('input').find(i=>i.id?.includes('mediaurl')||/url/i.test(i.placeholder||'')); const addEnd=$('#queue_end')||$('#addfromurl button.btn.btn-default:last-child')||$$('button').find(b=>/queue to end|queue|add/i.test(b.textContent||'')); if(!input||!addEnd){toast('Could not find Add controls');return} if(mode==='replace'){ const ok=await clearPlaylistRobust(); toast(ok?'Playlist cleared.':'Could not clear; loading anyway (append).'); } const current=getCurrentKeySet(); const toAdd=[]; for(const u of items){const k=urlToKey(u); if(!current.has(k)){toAdd.push(u); current.add(k);} } if(!toAdd.length){toast('Nothing to add');return} let i=0;(async function step(){ if(i>=toAdd.length){toast(`Loaded “${name}” (+${toAdd.length})`);return} input.value=toAdd[i++]; addEnd.click(); await sleep(180); step(); })(); }

  const shuffle=(a)=>{for(let i=a.length-1;i>0;i--){const j=(Math.random()*(i+1))|0;[a[i],a[j]]=[a[j],a[i]]}return a};
  async function addNFromSaved(name,count,where,rand){ if(!name){toast('Pick a playlist');return} const {items=[]}=readSaved(name); if(!items.length){toast('Saved list is empty');return} const current=getCurrentKeySet(); let pool=items.filter(u=>!current.has(urlToKey(u))); if(!pool.length){toast('Nothing to add');return} if(rand)shuffle(pool); const take=Math.max(1,Math.min(count|0,pool.length)); const sel=pool.slice(0,take); const input=$('#mediaurl')||$('input#mediaurl')||$$('input').find(i=>i.id?.includes('mediaurl')||/url/i.test(i.placeholder||'')); const addEndBtn=$('#queue_end')||$('#addfromurl button.btn.btn-default:last-child')||$$('button').find(b=>/queue to end|queue|add/i.test(b.textContent||'')); const addNextBtn=$('#queue_next')||$$('button').find(b=>/queue next|next/i.test(b.textContent||'')); if(!input||(!addEndBtn&&!addNextBtn)){toast('Could not find Add controls');return} const clicker=(where==='next'&&addNextBtn)?addNextBtn:addEndBtn; if(where==='next'&&!addNextBtn) toast('No “Queue Next”; adding to End.'); let i=0;(async function step(){ if(i>=sel.length){toast(`Added ${sel.length} from “${name}” (${where})`);return} input.value=sel[i++]; clicker.click(); await sleep(160); step(); })(); }

  let addUrlInput=null; // expose for clearing
  function appendUrlToSaved(name,url){ if(!name){toast('Pick a playlist');return} const u=String(url||'').trim(); if(!u){toast('Enter a URL');return} const data=readSaved(name); const keys=new Set((data.items||[]).map(urlToKey)); const k=urlToKey(u); if(keys.has(k)){toast('Already in list');return} data.items=[...(data.items||[]),u]; data.savedAt=Date.now(); writeSaved(name,data); try{ if(addUrlInput) { addUrlInput.value=''; addUrlInput.focus(); } }catch{} toast('Added to saved'); }

  function appendCurrentQueueToSaved(name){ if(!name){toast('Pick a playlist');return} const add=scrapeUrlsFromQueue(); if(!add.length){toast('Current queue is empty');return} const data=readSaved(name); const keys=new Set((data.items||[]).map(urlToKey)); let added=0; for(const u of add){const k=urlToKey(u); if(!keys.has(k)){data.items.push(u); keys.add(k); added++;}} data.savedAt=Date.now(); writeSaved(name,data); toast(added?`Appended ${added}`:'All already present'); }

  function openEditor(name){ if(!name){toast('Pick a playlist');return} const data=readSaved(name); const modal=document.createElement('div'); modal.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:100000;display:grid;place-items:center;'; const card=document.createElement('div'); card.style.cssText='width:min(900px,92vw);max-height:86vh;overflow:auto;background:rgba(18,18,18,.6);color:#fff;padding:14px;border-radius:16px;box-shadow:0 30px 80px rgba(0,0,0,.5);font:13px system-ui;backdrop-filter:blur(14px) saturate(140%);border:1px solid rgba(255,255,255,.18)'; const title=document.createElement('div'); title.textContent=`Edit: ${name}`; title.style.cssText='font-weight:700;margin-bottom:8px'; const ta=document.createElement('textarea'); ta.value=(data.items||[]).join('\n'); ta.style.cssText='width:100%;height:48vh;background:rgba(0,0,0,.35);color:#eee;padding:10px;border-radius:12px;border:1px solid rgba(255,255,255,.12)'; const row=document.createElement('div'); row.className='ct-row'; const btn=(label,fn)=>{const b=document.createElement('button'); b.textContent=label; b.className='ct-btn'; b.addEventListener('click',fn); return b;}; const dedupeBtn=btn('Dedupe',()=>{const lines=ta.value.split('\n').map(s=>s.trim()).filter(Boolean); const seen=new Set(); const out=[]; for(const u of lines){const k=urlToKey(u); if(!seen.has(k)){seen.add(k); out.push(u);} } ta.value=out.join('\n'); toast(`Deduped to ${out.length}`);}); const saveBtn=btn('Save',()=>{const lines=ta.value.split('\n').map(s=>s.trim()).filter(Boolean); writeSaved(name,{savedAt:Date.now(),items:lines}); toast('Saved'); document.body.removeChild(modal);}); const cancelBtn=btn('Cancel',()=>document.body.removeChild(modal)); row.append(dedupeBtn,saveBtn,cancelBtn); card.append(title,ta,row); modal.append(card); modal.addEventListener('click',(e)=>{if(e.target===modal)document.body.removeChild(modal)}); document.body.appendChild(modal); }

  function deleteName(name){ if(!name){toast('Pick a name');return} localStorage.removeItem(KEY(name)); const idx=setIndex(getIndex().filter(n=>n!==name)); refreshList(idx,idx[0]||''); if(usingRemote()&&DBSTATE.cache&&DBSTATE.cache.playlists){ delete DBSTATE.cache.playlists[name]; } toast(`Deleted “${name}”`); }

  // export/import
  function exportAll(){ let payload; if(DBSTATE.connected&&DBSTATE.cache) payload=DBSTATE.cache; else { const all={}; for(const n of getIndexLocal()){ const raw=localStorage.getItem(KEY(n)); if(raw) all[n]=JSON.parse(raw);} payload={channel:channelName,data:all}; } const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`cytube_saved_playlists_${channelName}.json`; a.click(); URL.revokeObjectURL(a.href); toast('Exported JSON'); }
  function importAll(file){ const fr=new FileReader(); fr.onload=async()=>{ try{ const parsed=JSON.parse(fr.result); if(DBSTATE.connected&&DBSTATE.cache){ if(parsed.playlists) DBSTATE.cache=parsed; else if(parsed.data) DBSTATE.cache.playlists={...DBSTATE.cache.playlists,...parsed.data}; else DBSTATE.cache.playlists={...DBSTATE.cache.playlists,...parsed}; await drivePush().catch(()=>toast('Push failed after import')); refreshList(getIndex(),''); toast('Imported to remote DB'); } else { const data=parsed.data||parsed; let idx=getIndexLocal(); for(const [name,payload] of Object.entries(data)){ localStorage.setItem(KEY(name),JSON.stringify(payload)); idx.push(name);} idx=setIndexLocal(idx); refreshList(idx,idx[0]||''); toast('Imported playlists (local)'); } }catch{ toast('Import failed (bad JSON)'); } }; fr.readAsText(file); }

  // auto sync
  const isAutoPush =()=> localStorage.getItem(AUTOPUSH)==='1';
  const setAutoPush=(v)=> localStorage.setItem(AUTOPUSH, v?'1':'0');
  const isAutoPull =()=> { const v=localStorage.getItem(AUTOPULL); return v==null ? true : v==='1'; };
  const setAutoPull=(v)=> localStorage.setItem(AUTOPULL, v?'1':'0');
  const getPullMs =()=>{ const n=Number(localStorage.getItem(AUTOPULL_MS_KEY)); return Number.isFinite(n)&&n>=MIN_PULL_MS ? n : DEFAULT_PULL_MS; };
  async function maybeAutoPush(){ if(DBSTATE.connected&&DBSTATE.cache&&isAutoPush()){ try{ await drivePush(); }catch(e){ console.error(e); toast('Auto-push failed'); } } }
  let pullTimer=null; const currentRev=()=>String(DBSTATE?.cache?.sync?.rev||'');
  function startAutoPull(){ stopAutoPull(); if(!DBSTATE.connected) return; if(!isAutoPull()) return; let pulling=false; const pullNow=async()=>{ if(pulling) return; pulling=true; try{ const before=currentRev(); await drivePull(); const after=currentRev(); if(before!==after){ refreshList(getIndex(),''); } }catch(e){} finally{ pulling=false; updateStatusChips(); } }; pullTimer=setInterval(pullNow, getPullMs()); pullNow(); document.addEventListener('visibilitychange', ()=>{ if(document.visibilityState==='visible') pullNow(); }); window.addEventListener('storage', (e)=>{ if(e.key===REV_BROADCAST_KEY) pullNow(); }); }
  function stopAutoPull(){ if(pullTimer){ clearInterval(pullTimer); pullTimer=null; } }

  // CSS
  function injectGlassCSS(){ if($('#ct-savedpl-style')) return; const css=`
#ct-savedpl-panel{position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:9999;margin:0;max-width:min(1100px,96vw);backdrop-filter:blur(16px) saturate(160%);-webkit-backdrop-filter:blur(16px) saturate(160%);background:rgba(20,20,22,.55);color:#fff;border-radius:16px;border:1px solid rgba(255,255,255,.18);box-shadow:0 20px 60px rgba(0,0,0,.45);font:12px/1.2 system-ui}
#ct-savedpl-header{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer}
#ct-savedpl-title{font-weight:700;letter-spacing:.2px}
.ct-chip{padding:4px 8px;border-radius:12px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14)}
#ct-savedpl-body{overflow:hidden;transition:max-height .25s ease, opacity .2s ease;opacity:1}
.ct-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 12px}
.ct-sep{height:1px;background:linear-gradient(90deg,transparent,rgba(255,255,255,.18),transparent);margin:4px 0}
.ct-btn{padding:6px 10px;cursor:pointer;border-radius:12px;border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.08);color:#fff}
.ct-input,.ct-select{padding:6px 8px;border-radius:10px;border:1px solid rgba(255,255,255,.16);background:rgba(34,34,38,.9);color:#fff;min-height:28px;appearance:none;-webkit-appearance:none;-moz-appearance:none}
.ct-select option{background:#1a1b1e;color:#fff}
.ct-tight{padding:4px 8px}
.ct-caret{user-select:none;font-size:14px;opacity:.9}
#ct-db-section{overflow:hidden;transition:max-height .25s ease, opacity .2s ease;opacity:1}
#ct-db-head{display:flex;align-items:center;gap:10px;padding:6px 12px}
#ct-status{margin-left:auto}
`; const style=document.createElement('style'); style.id='ct-savedpl-style'; style.textContent=css; document.head.appendChild(style);}

  // status chips
  function updateStatusChips(){ const txt=(DBSTATE.connected&&DBSTATE.dbId)?`Connected (${String(DBSTATE.dbId).slice(0,8)}…)`:'Local (offline)'; const header=$('#ct-status'); if(header) header.textContent=txt; const dbchip=$('#ct-db-head .ct-chip'); if(dbchip) dbchip.textContent=txt; }

  // UI globals
  let selectEl,modeEl,whereEl,countEl,nameInput;

  function refreshList(names,selectName){ if(!selectEl) return; selectEl.innerHTML=''; const ph=document.createElement('option'); ph.value=''; ph.textContent='(choose a playlist)'; selectEl.appendChild(ph); for(const n of names){ const o=document.createElement('option'); o.value=n; o.textContent=n; if(n===selectName) o.selected=true; selectEl.appendChild(o);} }
  function button(label,fn){ const b=document.createElement('button'); b.textContent=label; b.className='ct-btn'; b.addEventListener('click',fn); return b; }

  function attachDatabaseUI(container){
    const wrap=document.createElement('div');
    const head=document.createElement('div'); head.id='ct-db-head';
    const caret=document.createElement('span'); caret.className='ct-caret ct-btn ct-tight'; caret.textContent='▾';
    const title=document.createElement('span'); title.textContent='Database';
    const statusChip=document.createElement('span'); statusChip.className='ct-chip';

    // Toggles
    const autoPushChip=document.createElement('label'); autoPushChip.className='ct-chip'; autoPushChip.style.cursor='pointer';
    const autoPushCb=document.createElement('input'); autoPushCb.type='checkbox'; autoPushCb.style.marginRight='6px'; autoPushCb.checked=isAutoPush();
    autoPushChip.append(autoPushCb, document.createTextNode('Auto-Push'));

    const autoPullChip=document.createElement('label'); autoPullChip.className='ct-chip'; autoPullChip.style.cursor='pointer';
    const autoPullCb=document.createElement('input'); autoPullCb.type='checkbox'; autoPullCb.style.marginRight='6px'; autoPullCb.checked=isAutoPull();
    autoPullChip.append(autoPullCb, document.createTextNode('Auto-Sync'));

    head.append(caret,title,statusChip,autoPushChip,autoPullChip);

    const body=document.createElement('div'); body.id='ct-db-section';
    const row=document.createElement('div'); row.className='ct-row';

    autoPushCb.addEventListener('change',()=>{ setAutoPush(autoPushCb.checked); if(autoPushCb.checked) maybeAutoPush(); });
    autoPullCb.addEventListener('change',()=>{ setAutoPull(autoPullCb.checked); if(autoPullCb.checked) startAutoPull(); else stopAutoPull(); });

    const createBtn=button('Create', async()=>{
      const baseUrl=prompt('Paste your Google Apps Script Web App URL (ends with /exec):', DBSTATE.baseUrl||''); if(!baseUrl) return;
      try{ const dbId=await driveInit(baseUrl); DBSTATE.baseUrl=baseUrl; DBSTATE.dbId=dbId; DBSTATE.connected=true; await drivePull(); saveConnPerChannel(); updateStatusChips(); refreshList(getIndex(),''); startAutoPull(); toast('Database created & connected.'); }
      catch(e){ console.error(e); toast('Create failed'); }
    });
    const connectBtn=button('Connect', async()=>{
      const link=prompt('Paste DB link (https://.../exec?db=UUID[&token=SECRET])',''); if(!link) return;
      try{ const u=new URL(link); DBSTATE.baseUrl=link.split('?')[0]; DBSTATE.dbId=u.searchParams.get('db')||''; DBSTATE.token=u.searchParams.get('token')||''; if(!DBSTATE.dbId){ toast('Missing db param'); return; } DBSTATE.connected=true; await drivePull(); saveConnPerChannel(); updateStatusChips(); refreshList(getIndex(),''); startAutoPull(); toast('Connected.'); }
      catch(e){ console.error(e); toast('Connect failed'); }
    });

    const pullBtn=button('Pull now', async()=>{ try{ await drivePull(); refreshList(getIndex(),''); updateStatusChips(); toast('Pulled.'); }catch(e){ toast('Pull failed'); }});
    const pushBtn=button('Push', async()=>{ if(!DBSTATE.connected){toast('Not connected');return} try{ await drivePush(); toast('Pushed.'); }catch(e){ console.error(e); toast(String(e.message||e)); } });
    const copyBtn=button('Copy Link',()=>{ if(!DBSTATE.connected){toast('Not connected');return} const link=`${DBSTATE.baseUrl}?db=${encodeURIComponent(DBSTATE.dbId)}${DBSTATE.token?`&token=${encodeURIComponent(DBSTATE.token)}`:''}`; navigator.clipboard.writeText(link).then(()=>toast('DB link copied')); });
    const debugBtn=button('Debug',()=>{ alert(`[CyTube DB Debug]\nconnected: ${DBSTATE.connected}\nbaseUrl: ${DBSTATE.baseUrl}\ndbId: ${DBSTATE.dbId}\ntokenSet: ${DBSTATE.token?'yes':'no'}\ncache?: ${DBSTATE.cache?'yes':'no'}\nautoPush: ${isAutoPush()?'on':'off'}\nautoPull: ${isAutoPull()?'on':'off'}\npullMs: ${getPullMs()} ms`); });
    const discBtn=button('Disconnect',()=>{ DBSTATE.connected=false; DBSTATE.baseUrl=''; DBSTATE.dbId=''; DBSTATE.token=''; DBSTATE.cache=null; localStorage.removeItem(`ct_db_conn:${channelName}`); updateStatusChips(); refreshList(getIndex(),''); stopAutoPull(); toast('Disconnected (local mode).'); });

    row.append(createBtn,connectBtn,pullBtn,pushBtn,copyBtn,debugBtn,discBtn);
    body.append(row);

    function syncDbCollapsedUI(){ const collapsed=localStorage.getItem(UIKEY_DB)==='1'; caret.textContent=collapsed?'▸':'▾'; if(!collapsed){ body.style.maxHeight='none'; body.style.opacity='1'; body.setAttribute('aria-hidden','false'); } else { body.style.maxHeight=(body.scrollHeight||0)+'px'; requestAnimationFrame(()=>{ body.style.maxHeight='0px'; body.style.opacity='0'; body.setAttribute('aria-hidden','true'); }); } }
    const toggleDb=()=>{
      const now=localStorage.getItem(UIKEY_DB)==='1'?'0':'1';
      localStorage.setItem(UIKEY_DB,now);
      if(now==='0'){
        body.style.maxHeight=body.scrollHeight+'px';
        body.style.opacity='1';
        body.setAttribute('aria-hidden','false');
        setTimeout(()=>{ body.style.maxHeight='none'; },260);
      }
      // also make sure the main panel body can grow when DB expands
      try{
        const mainBody=document.getElementById('ct-savedpl-body');
        if(mainBody){
          if(now==='0'){ // expanding DB
            mainBody.style.maxHeight='none';
            mainBody.style.opacity='1';
          }
        }
      }catch{}
      syncDbCollapsedUI();
    };
    caret.addEventListener('click',toggleDb);
    head.addEventListener('click',(e)=>{ if(e.target!==caret) toggleDb(); });

    wrap.append(head,body); container.append(wrap);
    updateStatusChips(); syncDbCollapsedUI();
  }

  function injectUI(){
    try{
      if($('#ct-savedpl-panel')) return;
      injectGlassCSS();
      const panel=document.createElement('div'); panel.id='ct-savedpl-panel';
      const header=document.createElement('div'); header.id='ct-savedpl-header';
      const caret=document.createElement('span'); caret.className='ct-caret ct-btn ct-tight'; caret.textContent='▾';
      const title=document.createElement('div'); title.id='ct-savedpl-title'; title.textContent='Saved Playlists';
      const chan=document.createElement('span'); chan.className='ct-chip'; chan.textContent=`${channelName}`;
      const status=document.createElement('span'); status.id='ct-status'; status.className='ct-chip'; status.textContent='Local (offline)';
      header.append(caret,title,chan,status); panel.append(header);

      const body=document.createElement('div'); body.id='ct-savedpl-body'; panel.append(body);

      const row1=document.createElement('div'); row1.className='ct-row';
      const row2=document.createElement('div'); row2.className='ct-row';
      const row3=document.createElement('div'); row3.className='ct-row';
      const row4=document.createElement('div'); row4.className='ct-row';
      const row5=document.createElement('div'); row5.className='ct-row';

      // Row 1 — Load/Delete
      const select=document.createElement('select'); select.className='ct-select'; select.style.minWidth='180px'; selectEl=select;
      const mode=document.createElement('select'); mode.className='ct-select'; mode.innerHTML='<option value="replace">Replace</option><option value="append">Append</option>'; modeEl=mode;
      const loadBtn=button('Load (All)',()=>loadWhole(selectEl.value, modeEl.value));
      const delBtn=button('Delete',()=>deleteName(selectEl.value));
      row1.append(selectEl,modeEl,loadBtn,delBtn);

      // Row 2 — Add N
      const whereSel=document.createElement('select'); whereSel.className='ct-select'; whereSel.innerHTML='<option value="next">Next</option><option value="end">End</option>'; whereEl=whereSel;
      const countInput=document.createElement('input'); countInput.type='number'; countInput.min='1'; countInput.value='5'; countInput.className='ct-input'; countInput.style.width='72px'; countEl=countInput;
      const randomWrap=document.createElement('label'); randomWrap.className='ct-chip'; randomWrap.style.cursor='pointer';
      const randomCb=document.createElement('input'); randomCb.type='checkbox'; randomCb.checked=true; randomCb.style.marginRight='6px';
      randomWrap.append(randomCb, document.createTextNode('Random'));
      const addNBtn=button('Add N',()=>{ const n=parseInt(countEl.value,10)||1; const where=whereEl.value; addNFromSaved(selectEl.value,n,where,randomCb.checked); });
      row2.append(whereEl,countEl,randomWrap,addNBtn);

      // Row 3 — Save As
      const nameIn=document.createElement('input'); nameIn.placeholder='Save As… (name)'; nameIn.className='ct-input'; nameIn.style.minWidth='180px'; nameInput=nameIn;
      const saveBtn=button('Save As',()=>saveAs(nameInput.value.trim()));
      row3.append(nameInput,saveBtn);

      // Row 4 — Edit/Append
      const urlIn=document.createElement('input'); urlIn.placeholder='Add URL to saved…'; urlIn.className='ct-input'; urlIn.style.minWidth='260px'; addUrlInput=urlIn;
      const addUrlBtn=button('Add URL',()=>appendUrlToSaved(selectEl.value, addUrlInput.value));
      const addCurrentBtn=button('Add Current',()=>appendCurrentQueueToSaved(selectEl.value));
      const editBtn=button('Edit…',()=>openEditor(selectEl.value));
      row4.append(addUrlInput,addUrlBtn,addCurrentBtn,editBtn);

      // Row 5 — Utilities
      const listBtn=button('List',()=>toast(getIndex().join(', ')||'No saved playlists'));
      const exportBtn=button('Export',exportAll);
      const importInput=document.createElement('input'); importInput.type='file'; importInput.accept='application/json'; importInput.style.display='none';
      importInput.addEventListener('change',()=>{ if(importInput.files?.[0]) importAll(importInput.files[0]); importInput.value=''; });
      const importBtn=button('Import',()=>importInput.click());
      row5.append(listBtn,exportBtn,importBtn,importInput);

      const sep=()=>{ const d=document.createElement('div'); d.className='ct-sep'; return d; };
      body.append(row1,sep(),row2,sep(),row3,sep(),row4,sep(),row5,sep());

      attachDatabaseUI(body);

      document.body.prepend(panel);
      refreshList(getIndex(),'');

      // Collapsible main
      function syncMain(){
        const collapsed=localStorage.getItem(UIKEY_MAIN)==='1';
        caret.textContent=collapsed?'▸':'▾';
        const ensureAuto=()=>{ body.style.maxHeight='none'; body.style.opacity='1'; };
        if(collapsed){
          // collapse with transition
          body.style.maxHeight = (body.scrollHeight||0)+'px';
          requestAnimationFrame(()=>{ body.style.maxHeight='0px'; body.style.opacity='0'; });
        } else {
          // expand and then free the height so inner sections can grow
          body.style.maxHeight = (body.scrollHeight||0)+'px';
          body.style.opacity='1';
                   setTimeout(ensureAuto, 260);
        }
      }
      header.addEventListener('click',()=>{ const now=localStorage.getItem(UIKEY_MAIN)==='1'?'0':'1'; localStorage.setItem(UIKEY_MAIN,now); syncMain(); });
      requestAnimationFrame(syncMain);

      // Auto-connect
      if(loadConnPerChannel()){
        updateStatusChips();
        (async()=>{ try{ await drivePull(); refreshList(getIndex(),''); }catch{} startAutoPull(); })();
      }
    }catch(e){ console.error('[Glass] UI init failed:',e); toast('Glass UI failed: '+(e.message||e)); }
  }

  // build UI
  try{
    if(document.readyState==='complete' || document.readyState==='interactive') injectUI();
    else document.addEventListener('DOMContentLoaded', injectUI);
  }catch(e){ console.error('[Glass] boot error',e); }
})();