Last active
May 24, 2026 05:48
-
-
Save vdeemann/ee27733a563ddaae47024c5b762b39e6 to your computer and use it in GitHub Desktop.
Search across one, several, or all of your playlists at once on deepcut.live.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Deepcut Global Playlist Search | |
| // @namespace https://github.com/vdeemann | |
| // @version 0.9.9 | |
| // @description Search across one, several, or all of your playlists at once on deepcut.live. | |
| // @match https://deepcut.live/* | |
| // @match https://www.deepcut.live/* | |
| // @match https://*.neader.com/* | |
| // @grant none | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ---------- config ---------- | |
| const API_BASE = 'https://70s.neader.com/api'; | |
| const CONCURRENCY = 3; | |
| const SELECTED_KEY = 'dcgs.selectedPlaylists'; | |
| const CACHE_TTL_MS = 5 * 60 * 1000; | |
| // Gap (px) between the FAB and the anchor pill. | |
| const FAB_PILL_GAP_PX = 40; | |
| // Fallback FAB position when no status pill is rendered (e.g. listener in | |
| // someone else's room without DJ Auto-Muter, or own room with Auto-Muter | |
| // disabled). Anchored from the bottom-right to sit just above the footer | |
| // links, to the left of the chat input area — the same horizontal band | |
| // the FAB occupies when anchored to a status pill. | |
| const FAB_FALLBACK_BOTTOM_PX = 14; | |
| const FAB_FALLBACK_RIGHT_PX = 280; | |
| // ---------- auth ---------- | |
| // Try a few likely places where deepcut exposes the logged-in user. | |
| // Fall back to letting cookies do the work via credentials: 'include'. | |
| function getAuth() { | |
| const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; | |
| const tryObjs = [ | |
| w.turntable && w.turntable.user, | |
| w.tt && w.tt.user, | |
| w.user, | |
| w.currentUser, | |
| w.userInfo, | |
| ]; | |
| for (const u of tryObjs) { | |
| if (!u) continue; | |
| const id = u.userid || u.id || u._id; | |
| const auth = u.userauth || u.auth || u.authToken; | |
| if (id && auth) return { userid: id, userauth: auth }; | |
| } | |
| return {}; | |
| } | |
| // ---------- API ---------- | |
| async function apiCall(endpoint, body) { | |
| const payload = Object.assign( | |
| { client: 'web', decache: Date.now() }, | |
| getAuth(), | |
| body | |
| ); | |
| const res = await fetch(`${API_BASE}/${endpoint}`, { | |
| method: 'POST', | |
| credentials: 'include', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| if (!res.ok) throw new Error(`${endpoint} HTTP ${res.status}`); | |
| const data = await res.json(); | |
| if (Array.isArray(data) && data[0] === true) return data[1]; | |
| const reason = (data && data[1] && data[1].err) || JSON.stringify(data); | |
| throw new Error(`${endpoint} returned error: ${reason}`); | |
| } | |
| async function listAllPlaylists() { | |
| const res = await apiCall('playlist.list_all', {}); | |
| return res.list; // [{ active, name }, ...] | |
| } | |
| async function getPlaylistSongs(playlistName) { | |
| // Omit `minimal` so we get metadata (song title, artist). | |
| const res = await apiCall('playlist.all', { playlist_name: playlistName }); | |
| return res.list; // each song: { _id, source, sourceid, created, metadata: { song, artist, length, coverart, scid } } | |
| } | |
| async function switchPlaylist(playlistName) { | |
| return apiCall('playlist.switch', { playlist_name: playlistName }); | |
| } | |
| async function reorderPlaylist(playlistName, indexFrom, indexTo) { | |
| return apiCall('playlist.reorder', { | |
| playlist_name: playlistName, | |
| index_from: indexFrom, | |
| index_to: indexTo, | |
| }); | |
| } | |
| // ---------- cache ---------- | |
| const playlistCache = new Map(); // name -> { fetchedAt, songs } | |
| async function getPlaylistSongsCached(name) { | |
| const hit = playlistCache.get(name); | |
| if (hit && Date.now() - hit.fetchedAt < CACHE_TTL_MS) return hit.songs; | |
| const songs = await getPlaylistSongs(name); | |
| playlistCache.set(name, { fetchedAt: Date.now(), songs }); | |
| return songs; | |
| } | |
| // ---------- search ---------- | |
| function matchesQuery(song, q) { | |
| const m = song.metadata || {}; | |
| const haystack = `${m.song || ''} ${m.artist || ''}`.toLowerCase(); | |
| return haystack.includes(q); | |
| } | |
| async function searchPlaylists(playlistNames, query, onProgress) { | |
| const q = query.trim().toLowerCase(); | |
| if (!q) return []; | |
| const results = []; | |
| let inFlight = 0; | |
| let nextIdx = 0; | |
| let done = 0; | |
| return new Promise((resolve) => { | |
| const launch = () => { | |
| while (inFlight < CONCURRENCY && nextIdx < playlistNames.length) { | |
| const name = playlistNames[nextIdx++]; | |
| inFlight++; | |
| getPlaylistSongsCached(name) | |
| .then(songs => { | |
| for (let i = 0; i < songs.length; i++) { | |
| const s = songs[i]; | |
| if (matchesQuery(s, q)) { | |
| results.push({ playlist: name, song: s, index: i }); | |
| } | |
| } | |
| }) | |
| .catch(err => { | |
| console.warn(`[deepcut-search] failed for "${name}":`, err); | |
| }) | |
| .finally(() => { | |
| inFlight--; | |
| done++; | |
| if (onProgress) onProgress(done, playlistNames.length, results.length); | |
| if (done === playlistNames.length) resolve(results); | |
| else launch(); | |
| }); | |
| } | |
| }; | |
| launch(); | |
| }); | |
| } | |
| // ---------- UI ---------- | |
| function injectStyles() { | |
| const css = ` | |
| #dcgs-fab { | |
| position: fixed; z-index: 99998; | |
| height: 32px; padding: 0 14px; border-radius: 16px; | |
| background: rgba(20,20,20,.85); color: #eee; | |
| border: 1px solid #444; cursor: pointer; | |
| font: 600 13px/30px system-ui, sans-serif; text-align: center; | |
| box-shadow: 0 2px 8px rgba(0,0,0,.4); | |
| display: none; /* shown once positioned */ | |
| } | |
| #dcgs-fab:hover { background: rgba(40,40,40,.95); border-color: #666; } | |
| #dcgs-fab.visible { display: inline-block; } | |
| #dcgs-panel { | |
| position: fixed; z-index: 99999; | |
| width: 440px; max-height: 50vh; | |
| background: #1a1a1a; color: #eee; border: 1px solid #333; | |
| border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,.6); | |
| font: 14px/1.4 system-ui, sans-serif; overflow: hidden; | |
| flex-direction: column; display: none; | |
| } | |
| #dcgs-panel.open { display: flex; } | |
| #dcgs-panel header { | |
| padding: 10px 12px; background: #222; border-bottom: 1px solid #333; | |
| display: flex; gap: 8px; align-items: center; | |
| } | |
| #dcgs-q { | |
| flex: 1; padding: 6px 10px; background: #111; color: #eee; | |
| border: 1px solid #444; border-radius: 4px; font-size: 14px; | |
| } | |
| #dcgs-scope { | |
| background: #111; color: #eee; border: 1px solid #444; | |
| border-radius: 4px; padding: 6px; | |
| } | |
| #dcgs-playlists { | |
| max-height: 160px; overflow-y: auto; padding: 8px 12px; | |
| background: #161616; border-bottom: 1px solid #333; display: none; | |
| } | |
| #dcgs-playlists.open { display: block; } | |
| #dcgs-playlists label { | |
| display: block; padding: 2px 0; cursor: pointer; user-select: none; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| } | |
| #dcgs-playlists .pl-tools { | |
| display: flex; gap: 8px; padding-bottom: 6px; | |
| border-bottom: 1px solid #333; margin-bottom: 6px; | |
| } | |
| #dcgs-playlists .pl-tools button { | |
| background: #2a2a2a; color: #eee; border: 1px solid #444; | |
| border-radius: 3px; padding: 2px 8px; cursor: pointer; font-size: 12px; | |
| } | |
| #dcgs-results { | |
| flex: 1; overflow-y: auto; min-height: 80px; max-height: 32vh; | |
| } | |
| .dcgs-result { | |
| padding: 8px 12px; border-bottom: 1px solid #2a2a2a; cursor: pointer; | |
| } | |
| .dcgs-result:hover { background: #232323; } | |
| .dcgs-result.queueing { background: #2a2a1a; } | |
| .dcgs-result.queued { background: #1f2a1f; } | |
| .dcgs-result .title { color: #fff; font-weight: 500; } | |
| .dcgs-result .meta { color: #888; font-size: 12px; margin-top: 2px; } | |
| .dcgs-result .pl { color: #d4af37; } | |
| #dcgs-status { | |
| padding: 6px 12px; font-size: 12px; color: #888; | |
| border-top: 1px solid #333; background: #161616; | |
| } | |
| `; | |
| const style = document.createElement('style'); | |
| style.textContent = css; | |
| document.head.appendChild(style); | |
| } | |
| function buildUI() { | |
| const fab = document.createElement('button'); | |
| fab.id = 'dcgs-fab'; | |
| fab.title = 'Global playlist search'; | |
| fab.textContent = '🔎 Search playlists'; | |
| document.body.appendChild(fab); | |
| const panel = document.createElement('div'); | |
| panel.id = 'dcgs-panel'; | |
| panel.innerHTML = ` | |
| <header> | |
| <input id="dcgs-q" type="text" placeholder="Search title or artist…" autocomplete="off"/> | |
| <select id="dcgs-scope"> | |
| <option value="all">All playlists</option> | |
| <option value="some">Selected…</option> | |
| </select> | |
| </header> | |
| <div id="dcgs-playlists"></div> | |
| <div id="dcgs-results"></div> | |
| <div id="dcgs-status">Click to load playlists.</div> | |
| `; | |
| document.body.appendChild(panel); | |
| fab.addEventListener('click', () => { | |
| panel.classList.toggle('open'); | |
| if (panel.classList.contains('open')) { | |
| positionPanel(); | |
| document.getElementById('dcgs-q').focus(); | |
| ensurePlaylistsLoaded(); | |
| } | |
| }); | |
| // Watch for layout changes so we re-position when relevant elements | |
| // mount/unmount. Throttled via rAF to avoid hammering on chat updates. | |
| positionFab(); | |
| let pending = false; | |
| const schedule = () => { | |
| if (pending) return; | |
| pending = true; | |
| requestAnimationFrame(() => { pending = false; positionFab(); }); | |
| }; | |
| const observer = new MutationObserver(schedule); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| window.addEventListener('resize', () => { positionFab(); positionPanel(); }); | |
| } | |
| // Locate the status pill in the bottom area of the screen. | |
| // - "● DJ MUTER" / "DJ MUTER": shown to room owner when not actively DJing | |
| // - "● PLAYING": shown to room owner when DJing | |
| // - "● MUTED" / "MUTED": shown to listeners (non-owner) in any room | |
| // | |
| // Returns the smallest matching element (the pill itself, not a wrapper), | |
| // or null if none is currently rendered. | |
| function findStatusPill() { | |
| const all = document.querySelectorAll('button, a, div, span'); | |
| let best = null; | |
| for (const el of all) { | |
| const t = (el.textContent || '').trim().toLowerCase(); | |
| const isPill = | |
| t === 'dj muter' || t === '● dj muter' || t.endsWith('dj muter') || | |
| t === 'playing' || t === '● playing' || t.endsWith('playing') || | |
| t === 'muted' || t === '● muted' || t.endsWith('muted'); | |
| if (!isPill) continue; | |
| const w = el.offsetWidth, h = el.offsetHeight; | |
| if (w <= 0 || h <= 0 || w >= 300 || h >= 80) continue; | |
| // Prefer the smallest matching element. | |
| if (!best || (w * h) < (best.offsetWidth * best.offsetHeight)) { | |
| best = el; | |
| } | |
| } | |
| return best; | |
| } | |
| // Are we currently inside a deepcut room with the UI actually loaded? | |
| // | |
| // We require a real DOM signal from the room's playable UI — not just the | |
| // URL — because the URL (/<room_name>) is set the instant navigation | |
| // begins, but the room's stage and controls take a moment to render. URL | |
| // alone would cause the FAB to flash in over the blank loading skeleton. | |
| // | |
| // Accepted signals (any one is sufficient): | |
| // - #playlist-display: deepcut's playlist dropdown trigger. Mounts | |
| // after the stage renders, present in both your own room (always) | |
| // and others' rooms when the Queue tab is open. | |
| // - A status pill (DJ MUTER / PLAYING / MUTED): renders once the | |
| // user's audio state is established. Some pill variants come from | |
| // companion userscripts (DJ Auto-Muter provides "MUTED"); deepcut's | |
| // own "DJ MUTER" / "PLAYING" are always present in your own room. | |
| // | |
| // Requiring BOTH would be too strict — disabling a companion userscript | |
| // shouldn't hide the FAB. Requiring just the pill missed listener rooms | |
| // when Auto-Muter wasn't installed. Either-or covers both cases. | |
| function isInRoom() { | |
| const p = location.pathname; | |
| const knownNonRoom = /^\/(lobby|signin|signup|rooms|settings|profile|help)?\/?$/; | |
| if (!p || p.length <= 1 || knownNonRoom.test(p)) return false; | |
| if (document.getElementById('playlist-display')) return true; | |
| if (findStatusPill()) return true; | |
| return false; | |
| } | |
| function positionFab() { | |
| const fab = document.getElementById('dcgs-fab'); | |
| if (!fab) return; | |
| if (!isInRoom()) { | |
| // Not in a fully-loaded room. Hide the FAB; close the panel if open. | |
| fab.classList.remove('visible'); | |
| const panel = document.getElementById('dcgs-panel'); | |
| if (panel) panel.classList.remove('open'); | |
| return; | |
| } | |
| const pill = findStatusPill(); | |
| if (pill) { | |
| // Anchor next to the pill. Align vertical centers (not bottom edges) | |
| // so text baselines look right despite different pill/FAB heights. | |
| const r = pill.getBoundingClientRect(); | |
| const pillCenterY = r.top + r.height / 2; | |
| const fabHeight = fab.offsetHeight || 32; | |
| const fabBottom = window.innerHeight - (pillCenterY + fabHeight / 2); | |
| fab.style.bottom = fabBottom + 'px'; | |
| fab.style.left = ''; | |
| // `right` from viewport's right edge = distance from pill's left edge. | |
| fab.style.right = (window.innerWidth - r.left + FAB_PILL_GAP_PX) + 'px'; | |
| } else { | |
| // In a room but no pill rendered. Park in the fallback position so | |
| // the FAB stays usable. This happens for listener rooms without DJ | |
| // Auto-Muter installed — `#playlist-display` is what got us here. | |
| fab.style.bottom = FAB_FALLBACK_BOTTOM_PX + 'px'; | |
| fab.style.left = ''; | |
| fab.style.right = FAB_FALLBACK_RIGHT_PX + 'px'; | |
| } | |
| fab.classList.add('visible'); | |
| } | |
| function positionPanel() { | |
| const fab = document.getElementById('dcgs-fab'); | |
| const panel = document.getElementById('dcgs-panel'); | |
| if (!fab || !panel) return; | |
| const r = fab.getBoundingClientRect(); | |
| // Always above the FAB, right-aligned to it (FAB is right-anchored now). | |
| panel.style.bottom = (window.innerHeight - r.top + 8) + 'px'; | |
| panel.style.right = (window.innerWidth - r.right) + 'px'; | |
| panel.style.left = ''; | |
| } | |
| // ---------- state ---------- | |
| let allPlaylists = null; | |
| let loadingPlaylists = false; | |
| async function ensurePlaylistsLoaded() { | |
| if (allPlaylists || loadingPlaylists) return; | |
| loadingPlaylists = true; | |
| setStatus('Loading playlist list…'); | |
| try { | |
| allPlaylists = await listAllPlaylists(); | |
| renderPlaylistPicker(); | |
| setStatus(`${allPlaylists.length} playlists available. Type to search.`); | |
| } catch (e) { | |
| setStatus(`Failed to load playlists: ${e.message}`); | |
| console.error('[deepcut-search]', e); | |
| } finally { | |
| loadingPlaylists = false; | |
| } | |
| } | |
| function renderPlaylistPicker() { | |
| const container = document.getElementById('dcgs-playlists'); | |
| const saved = JSON.parse(localStorage.getItem(SELECTED_KEY) || '[]'); | |
| const savedSet = new Set(saved); | |
| const tools = ` | |
| <div class="pl-tools"> | |
| <button id="dcgs-pick-all">All</button> | |
| <button id="dcgs-pick-none">None</button> | |
| </div> | |
| `; | |
| const items = allPlaylists.map(p => { | |
| const checked = savedSet.has(p.name) ? 'checked' : ''; | |
| const active = p.active ? ' (active)' : ''; | |
| return `<label><input type="checkbox" value="${escapeAttr(p.name)}" ${checked}/> ${escapeHTML(p.name)}${active}</label>`; | |
| }).join(''); | |
| container.innerHTML = tools + items; | |
| container.addEventListener('change', () => { | |
| const picked = Array.from(container.querySelectorAll('input[type=checkbox]:checked')).map(i => i.value); | |
| localStorage.setItem(SELECTED_KEY, JSON.stringify(picked)); | |
| }); | |
| document.getElementById('dcgs-pick-all').addEventListener('click', () => { | |
| container.querySelectorAll('input[type=checkbox]').forEach(i => i.checked = true); | |
| container.dispatchEvent(new Event('change')); | |
| }); | |
| document.getElementById('dcgs-pick-none').addEventListener('click', () => { | |
| container.querySelectorAll('input[type=checkbox]').forEach(i => i.checked = false); | |
| container.dispatchEvent(new Event('change')); | |
| }); | |
| } | |
| function setStatus(msg) { | |
| const s = document.getElementById('dcgs-status'); | |
| if (s) s.textContent = msg; | |
| } | |
| function escapeHTML(s) { | |
| return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| } | |
| function escapeAttr(s) { return escapeHTML(s); } | |
| let lastResults = []; // for click lookup by data-idx | |
| function renderResults(results) { | |
| lastResults = results; | |
| const box = document.getElementById('dcgs-results'); | |
| if (results.length === 0) { | |
| box.innerHTML = '<div class="dcgs-result" style="cursor:default"><div class="meta">No matches.</div></div>'; | |
| return; | |
| } | |
| const shown = results.slice(0, 200); | |
| box.innerHTML = shown.map((r, i) => { | |
| const m = r.song.metadata || {}; | |
| const title = m.song || '(unknown title)'; | |
| const artist = m.artist || 'Unknown'; | |
| return ` | |
| <div class="dcgs-result" data-idx="${i}" title="Click to play next"> | |
| <div class="title">${escapeHTML(title)}</div> | |
| <div class="meta">${escapeHTML(artist)} · <span class="pl">${escapeHTML(r.playlist)}</span></div> | |
| </div> | |
| `; | |
| }).join(''); | |
| if (results.length > shown.length) { | |
| box.innerHTML += `<div class="dcgs-result" style="cursor:default"><div class="meta">…and ${results.length - shown.length} more. Refine your query.</div></div>`; | |
| } | |
| } | |
| // Switch to the song's playlist and move the song to position 0, then | |
| // bounce-and-back through the dropdown once to force the Queue panel | |
| // to refresh. API-only switches don't refresh the panel; we need a | |
| // real UI switch to trigger deepcut's re-render code path. | |
| // | |
| // Always re-fetches fresh playlist state before reordering — the cached | |
| // `result.index` from search time goes stale the moment deepcut rotates | |
| // played tracks to the bottom or the user moves things manually. | |
| async function queueSongAsNext(result, row) { | |
| const { playlist, song } = result; | |
| const title = song.metadata?.song || '(unknown)'; | |
| row.classList.remove('queued'); // clear prior success state on re-click | |
| row.classList.add('queueing'); | |
| try { | |
| // Bust the TTL cache and re-fetch — `result.index` may be stale. | |
| setStatus(`Checking position of "${title}"…`); | |
| playlistCache.delete(playlist); | |
| const freshSongs = await getPlaylistSongsCached(playlist); | |
| const currentIndex = freshSongs.findIndex(s => s._id === song._id); | |
| if (currentIndex === -1) { | |
| throw new Error(`song no longer in "${playlist}"`); | |
| } | |
| if (currentIndex !== 0) { | |
| setStatus(`Moving "${title}" to top of "${playlist}"…`); | |
| await reorderPlaylist(playlist, currentIndex, 0); | |
| // Update cache so subsequent searches reflect new order. | |
| const cached = playlistCache.get(playlist); | |
| if (cached) { | |
| const [moved] = cached.songs.splice(currentIndex, 1); | |
| cached.songs.unshift(moved); | |
| } | |
| // Fix up indices for any other results pointing into this playlist. | |
| for (const r of lastResults) { | |
| if (r.playlist === playlist) { | |
| if (r === result) r.index = 0; | |
| else if (r.index < currentIndex) r.index += 1; | |
| } | |
| } | |
| } | |
| // Refresh the Queue panel. After a reorder, the panel holds a stale | |
| // view; we need to bounce to another playlist and back to force a | |
| // full re-render from current playlist state. We do this regardless | |
| // of whether we were already on the target playlist — the API | |
| // reorder doesn't trigger a refresh on its own. | |
| const trigger = document.getElementById('playlist-display'); | |
| const bounce = pickBouncePlaylist(playlist); | |
| if (trigger && bounce) { | |
| setStatus(`Refreshing "${playlist}"…`); | |
| await clickPlaylistInDropdown(trigger, bounce); | |
| await sleep(250); | |
| const switched = await clickPlaylistInDropdown(trigger, playlist); | |
| if (!switched) { | |
| console.warn('[deepcut-search] UI switch failed, falling back to API'); | |
| await switchPlaylist(playlist); | |
| } | |
| // Close the dropdown if it's still hanging open. Turnstyles | |
| // sometimes prevents the dropdown from auto-closing after our | |
| // programmatic clicks. Check for the rendered <li.option.playlist> | |
| // items — if they're still visible, click the trigger to toggle | |
| // the dropdown shut. | |
| await sleep(100); | |
| const stillOpen = document.querySelectorAll('li.option.playlist').length > 0; | |
| if (stillOpen) { | |
| trigger.click(); | |
| } | |
| } else { | |
| // No dropdown or no other playlist to bounce through — API only. | |
| await switchPlaylist(playlist); | |
| } | |
| row.classList.remove('queueing'); | |
| row.classList.add('queued'); | |
| const verb = currentIndex === 0 ? 'is already next' : 'queued as next'; | |
| setStatus(`✓ "${title}" ${verb} in "${playlist}".`); | |
| markPlaylistActive(playlist); | |
| } catch (e) { | |
| row.classList.remove('queueing'); | |
| setStatus(`Failed: ${e.message}`); | |
| console.error('[deepcut-search]', e); | |
| } | |
| } | |
| // Read the currently-active playlist directly from the dropdown label. | |
| // This is the source of truth — in-memory `allPlaylists` can be stale if | |
| // the user has switched playlists manually since the script loaded. | |
| function getCurrentPlaylistFromDOM(trigger) { | |
| if (!trigger) return null; | |
| // The trigger contains the active playlist name as text. Try a few shapes: | |
| // <div id="playlist-display"><span>name</span>...</div> | |
| // <div id="playlist-display">name</div> | |
| const label = trigger.querySelector('.playlist-label, .selected-label, span'); | |
| const txt = ((label && label.textContent) || trigger.textContent || '').trim(); | |
| // Strip any trailing chevron/icon glyphs that might be in the text node. | |
| return txt.replace(/[▾▼⌄\u25BE\u25BC]\s*$/, '').trim() || null; | |
| } | |
| async function clickPlaylistInDropdown(trigger, playlistName) { | |
| // Open the dropdown (it auto-renders items) | |
| trigger.click(); | |
| // Wait for items to render in the DOM | |
| await sleep(120); | |
| // Real markup: <li class="option playlist"><span class="playlist-label">name</span>...</li> | |
| const items = document.querySelectorAll('li.option.playlist'); | |
| let hit = null; | |
| for (const li of items) { | |
| const label = li.querySelector('.playlist-label'); | |
| if (label && label.textContent.trim() === playlistName) { | |
| hit = li; | |
| break; | |
| } | |
| } | |
| if (!hit) { | |
| console.warn('[deepcut-search] no <li.option.playlist> with label', playlistName, | |
| '— available:', | |
| Array.from(items).map(li => li.querySelector('.playlist-label')?.textContent.trim())); | |
| return false; | |
| } | |
| // Dispatch full mouse sequence — some menus listen for mousedown, not click. | |
| ['mousedown', 'mouseup', 'click'].forEach(type => { | |
| hit.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); | |
| }); | |
| await sleep(150); // let deepcut process the switch | |
| return true; | |
| } | |
| function pickBouncePlaylist(avoid) { | |
| if (!allPlaylists || allPlaylists.length < 2) return null; | |
| const preferred = allPlaylists.find(p => p.name === 'default' && p.name !== avoid); | |
| if (preferred) return preferred.name; | |
| const other = allPlaylists.find(p => p.name !== avoid); | |
| return other ? other.name : null; | |
| } | |
| function sleep(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| function markPlaylistActive(name) { | |
| if (!allPlaylists) return; | |
| for (const p of allPlaylists) p.active = (p.name === name); | |
| // Re-render picker labels if it's visible. | |
| const picker = document.getElementById('dcgs-playlists'); | |
| if (picker && picker.classList.contains('open')) renderPlaylistPicker(); | |
| } | |
| function wireSearch() { | |
| const input = document.getElementById('dcgs-q'); | |
| const scope = document.getElementById('dcgs-scope'); | |
| const picker = document.getElementById('dcgs-playlists'); | |
| const resultsBox = document.getElementById('dcgs-results'); | |
| scope.addEventListener('change', () => { | |
| picker.classList.toggle('open', scope.value === 'some'); | |
| }); | |
| // Event delegation: clicks on result rows trigger queueing. | |
| resultsBox.addEventListener('click', async (ev) => { | |
| const row = ev.target.closest('.dcgs-result[data-idx]'); | |
| if (!row) return; | |
| const idx = parseInt(row.dataset.idx, 10); | |
| const result = lastResults[idx]; | |
| if (!result) return; | |
| await queueSongAsNext(result, row); | |
| }); | |
| let debounce; | |
| let runId = 0; | |
| input.addEventListener('input', () => { | |
| clearTimeout(debounce); | |
| debounce = setTimeout(runSearch, 300); | |
| }); | |
| async function runSearch() { | |
| const myRun = ++runId; | |
| const q = input.value.trim(); | |
| if (!q) { | |
| renderResults([]); | |
| setStatus(`${(allPlaylists||[]).length} playlists available. Type to search.`); | |
| return; | |
| } | |
| await ensurePlaylistsLoaded(); | |
| if (!allPlaylists) return; | |
| let target; | |
| if (scope.value === 'all') { | |
| target = allPlaylists.map(p => p.name); | |
| } else { | |
| const picked = JSON.parse(localStorage.getItem(SELECTED_KEY) || '[]'); | |
| target = picked.length ? picked : allPlaylists.map(p => p.name); | |
| } | |
| setStatus(`Searching ${target.length} playlists…`); | |
| const results = await searchPlaylists(target, q, (done, total, hits) => { | |
| if (myRun !== runId) return; | |
| setStatus(`Searched ${done}/${total} playlists… (${hits} matches so far)`); | |
| }); | |
| if (myRun !== runId) return; | |
| results.sort((a, b) => { | |
| const am = (a.song.metadata?.song || '').toLowerCase(); | |
| const bm = (b.song.metadata?.song || '').toLowerCase(); | |
| return am.localeCompare(bm); | |
| }); | |
| renderResults(results); | |
| setStatus(`${results.length} matches across ${target.length} playlists.`); | |
| } | |
| } | |
| // ---------- boot ---------- | |
| function boot() { | |
| if (document.getElementById('dcgs-fab')) return; | |
| injectStyles(); | |
| buildUI(); | |
| wireSearch(); | |
| console.log('[deepcut-search] ready v0.9.9'); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', boot); | |
| } else { | |
| boot(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment