diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js index 228239cd76..09396f3525 100644 --- a/Extensions/loopyLoop.js +++ b/Extensions/loopyLoop.js @@ -1,7 +1,8 @@ // NAME: Loopy loop // AUTHOR: khanhas -// VERSION: 0.1 -// DESCRIPTION: Simple tool to help you practice hitting that note right. Right click at process bar to open up menu. +// VERSION: 0.7 +// DESCRIPTION: Right click on the progress bar to set song markers and skip sections. +// All points persist per song across sessions. /// @@ -15,6 +16,11 @@ return; } + function getBar() { + const pc = document.querySelector(".playback-progressbar-container"); + return pc?.querySelector('input[type="range"]')?.closest("label")?.nextElementSibling ?? null; + } + const style = document.createElement("style"); style.innerHTML = ` #loopy-loop-start, #loopy-loop-end { @@ -22,6 +28,23 @@ font-weight: bolder; font-size: 15px; top: -7px; + cursor: context-menu; + z-index: 10; + padding: 2px 4px; +} +.loopy-skip-marker { + position: absolute; + font-weight: bolder; + font-size: 15px; + top: -7px; + color: #e74c3c; + cursor: context-menu; + z-index: 20; + padding: 2px 6px; +} +#loopy-context-menu, #loopy-move-submenu { + position: fixed; + z-index: 2147483647; } `; @@ -34,96 +57,539 @@ startMark.style.position = endMark.style.position = "absolute"; startMark.hidden = endMark.hidden = true; - bar.append(style); - bar.append(startMark); - bar.append(endMark); + document.head.append(style); + bar.append(startMark, endMark); let start = null; let end = null; let mouseOnBarPercent = 0.0; + let skipZones = []; + let pendingSkipStart = null; + let lastSkipSeek = 0; + let lastSkippedZoneIdx = -1; + let lastNextCall = 0; + let seekStartPendingUri = null; + let lastStartEnforce = 0; + let prevProgressPercent = -1; + let prevPressedAt = 0; // timestamp of first prev press; second press within 1.5s skips to prev song + let navigatingBack = false; // true after back() is called; cleared in songchange to skip stale onprogress ticks + let activeMarkerType = null; // "start" | "end" | "zoneStart" | "zoneEnd" | "zone" | null function drawOnBar() { - if (start === null && end === null) { - startMark.hidden = endMark.hidden = true; - return; + const currentBar = getBar(); + if (currentBar && startMark.parentElement !== currentBar) { + currentBar.append(startMark, endMark); + } + startMark.hidden = start === null; + endMark.hidden = end === null; + if (start !== null) startMark.style.left = `${start * 100}%`; + if (end !== null) endMark.style.left = `${end * 100}%`; + } + + function drawSkipMarkers() { + const currentBar = getBar() ?? bar; + if (!currentBar) return; + currentBar.querySelectorAll(".loopy-skip-marker").forEach((el) => el.remove()); + skipZones.forEach((zone, index) => { + const s = document.createElement("div"); + s.className = "loopy-skip-marker"; + s.innerText = "{"; + s.style.left = `${zone.start * 100}%`; + s.dataset.zoneIndex = String(index); + s.dataset.zoneSide = "start"; + + const e = document.createElement("div"); + e.className = "loopy-skip-marker"; + e.innerText = "}"; + e.style.left = `${zone.end * 100}%`; + e.dataset.zoneIndex = String(index); + e.dataset.zoneSide = "end"; + + currentBar.append(e, s); // { appended after } so { sits higher in stacking order + }); + + if (pendingSkipStart !== null) { + const p = document.createElement("div"); + p.className = "loopy-skip-marker"; + p.innerText = "{"; + p.style.left = `${pendingSkipStart * 100}%`; + p.style.opacity = "0.4"; + currentBar.append(p); } - startMark.hidden = endMark.hidden = false; - startMark.style.left = `${start * 100}%`; - endMark.style.left = `${end * 100}%`; } - function reset() { + + function saveState() { + const uri = Spicetify.Player.data?.item?.uri; + if (!uri) return; + Spicetify.LocalStorage.set(`loopyLoop:${uri}`, JSON.stringify({ start, end, skipZones })); + } + + function loadState() { + const uri = Spicetify.Player.data?.item?.uri; start = null; end = null; - drawOnBar(); + skipZones = []; + pendingSkipStart = null; + if (!uri) return; + try { + const saved = Spicetify.LocalStorage.get(`loopyLoop:${uri}`); + if (saved) { + const data = JSON.parse(saved); + start = data.start ?? null; + end = data.end ?? null; + skipZones = Array.isArray(data.skipZones) ? data.skipZones : []; + } + } catch (_) {} } - let debouncing = 0; - Spicetify.Player.addEventListener("onprogress", (event) => { - if (start != null && end != null) { - if (debouncing) { - if (event.timeStamp - debouncing > 1000) { - debouncing = 0; + // Position menu within viewport using fixed positioning + function openMenu(menu, x, y) { + menu.style.left = "-9999px"; + menu.style.top = "0px"; + menu.hidden = false; + const { height, width } = menu.getBoundingClientRect(); + menu.style.left = Math.min(x, window.innerWidth - width - 4) + "px"; + menu.style.top = Math.max(0, y - height) + "px"; + } + + function openContextMenu(x, y) { + skipStartBtn.querySelector("button").textContent = pendingSkipStart !== null ? "Cancel skip start" : "Set section skip start"; + openMenu(contextMenu, x, y); + } + + // Configure the conditional bottom section of the context menu + function setupActiveMarker(type, zoneIdx) { + activeMarkerType = type; + activeZoneIndex = zoneIdx ?? -1; + const hasMarker = type !== null; + const isSpecificMarker = type !== null && type !== "zone"; + divider2.hidden = !hasMarker; + moveBtnItem.hidden = !isSpecificMarker; + removeActiveBtn.hidden = !hasMarker; + + const removeBtn = removeActiveBtn.querySelector("button"); + if (type === "start") { + removeBtn.textContent = "Remove song start"; + removeBtn.onclick = (ev) => { + ev.stopPropagation(); + start = null; + drawOnBar(); + saveState(); + contextMenu.hidden = true; + moveSubmenu.hidden = true; + }; + } else if (type === "end") { + removeBtn.textContent = "Remove song end"; + removeBtn.onclick = (ev) => { + ev.stopPropagation(); + end = null; + drawOnBar(); + saveState(); + contextMenu.hidden = true; + moveSubmenu.hidden = true; + }; + } else { + removeBtn.textContent = "Remove section"; + removeBtn.onclick = (ev) => { + ev.stopPropagation(); + if (activeZoneIndex >= 0) { + skipZones.splice(activeZoneIndex, 1); + saveState(); + drawSkipMarkers(); + activeZoneIndex = -1; } + contextMenu.hidden = true; + moveSubmenu.hidden = true; + }; + } + } + + // Move submenu + const moveSubmenu = document.createElement("div"); + moveSubmenu.id = "loopy-move-submenu"; + moveSubmenu.innerHTML = ``; + moveSubmenu.hidden = true; + document.body.append(moveSubmenu); + + function applyMoveAdjustment(deltaSeconds) { + const durationMs = Spicetify.Player.getDuration(); + if (!durationMs) return; + const delta = (deltaSeconds * 1000) / durationMs; + if (activeMarkerType === "start") { + start = Math.max(0, Math.min(end !== null ? end - 1e-6 : 1, start + delta)); + drawOnBar(); + } else if (activeMarkerType === "end") { + end = Math.max(start !== null ? start + 1e-6 : 0, Math.min(1, end + delta)); + drawOnBar(); + } else if (activeMarkerType === "zoneStart") { + if (activeZoneIndex < 0 || activeZoneIndex >= skipZones.length) return; + skipZones[activeZoneIndex].start = Math.max(0, Math.min(skipZones[activeZoneIndex].end - 1e-6, skipZones[activeZoneIndex].start + delta)); + drawSkipMarkers(); + } else if (activeMarkerType === "zoneEnd") { + if (activeZoneIndex < 0 || activeZoneIndex >= skipZones.length) return; + skipZones[activeZoneIndex].end = Math.max(skipZones[activeZoneIndex].start + 1e-6, Math.min(1, skipZones[activeZoneIndex].end + delta)); + drawSkipMarkers(); + } + saveState(); + } + + [-0.5, -0.1, -0.01, 0.01, 0.1, 0.5].forEach((delta) => { + const li = document.createElement("li"); + li.setAttribute("role", "menuitem"); + const btn = document.createElement("button"); + btn.classList.add("main-contextMenu-menuItemButton"); + btn.textContent = (delta > 0 ? "+" : "") + delta + "s"; + btn.onclick = (e) => { + e.stopPropagation(); + applyMoveAdjustment(delta); + }; + li.append(btn); + moveSubmenu.firstElementChild.append(li); + }); + + // Skip zone seeking only — no loop-back behavior + Spicetify.Player.addEventListener("onprogress", (event) => { + const ts = event?.timeStamp ?? performance.now(); + const percent = Spicetify.Player.getProgressPercent(); + + // Repeat-mode restart: song restarted from 0 after hitting ], seek to [ + if (seekStartPendingUri !== null && percent < 0.05) { + const currentUri = Spicetify.Player.data?.item?.uri; + if (currentUri === seekStartPendingUri && start !== null) { + seekStartPendingUri = null; + Spicetify.Player.seek(start); return; } - const percent = Spicetify.Player.getProgressPercent(); - if (percent > end || percent < start) { - debouncing = event.timeStamp; - Spicetify.Player.seek(start); + seekStartPendingUri = null; + } + + // Detect prev button press: jump to ~0 from past Spotify's 3-second restart threshold + const durationMs = Spicetify.Player.getDuration() || 0; + const threeSecFrac = durationMs > 0 ? 3000 / durationMs : 0.02; + const nearZeroFrac = durationMs > 0 ? 1500 / durationMs : 0.01; + if (prevProgressPercent > threeSecFrac && percent < nearZeroFrac) { + if (prevPressedAt > 0 && ts - prevPressedAt < 1500) { + // Second press within 1.5s — go to previous song + prevPressedAt = 0; + prevProgressPercent = percent; + navigatingBack = true; + setTimeout(() => { + navigatingBack = false; + }, 2000); + Spicetify.Player.back(); + return; + } else { + // First press — go to [ (or stay at 0 if no start set) + prevPressedAt = ts; + prevProgressPercent = percent; + if (start !== null) Spicetify.Player.seek(start); return; } } + prevProgressPercent = percent; + + // Song start enforcement: seek to [ if playback is before it (covers song load + manual scrub) + if (start !== null && percent < start) { + if (navigatingBack) return; + if (ts - lastStartEnforce > 500) { + lastStartEnforce = ts; + Spicetify.Player.seek(start); + } + return; + } + + // Song end enforcement: advance to next track when playback reaches ] + if (end !== null && percent >= end) { + if (ts - lastNextCall > 2000) { + lastNextCall = ts; + seekStartPendingUri = Spicetify.Player.data?.item?.uri ?? null; + Spicetify.Player.next(); + } + return; + } + + // Skip zone seeking + if (skipZones.length > 0) { + let inZone = false; + for (let i = 0; i < skipZones.length; i++) { + const zone = skipZones[i]; + if (percent >= zone.start && percent < zone.end) { + inZone = true; + if (i !== lastSkippedZoneIdx || ts - lastSkipSeek > 500) { + lastSkipSeek = ts; + lastSkippedZoneIdx = i; + Spicetify.Player.seek(zone.end); + } + break; + } + } + if (!inZone) lastSkippedZoneIdx = -1; + } }); - Spicetify.Player.addEventListener("songchange", reset); + Spicetify.Player.addEventListener("songchange", () => { + navigatingBack = false; + // Clear seekStartPendingUri only when the new song differs — preserves repeat-one seek-to-[ behavior + if (Spicetify.Player.data?.item?.uri !== seekStartPendingUri) seekStartPendingUri = null; + loadState(); + drawOnBar(); + drawSkipMarkers(); + prevProgressPercent = -1; + prevPressedAt = 0; + lastStartEnforce = 0; + lastNextCall = 0; + lastSkipSeek = 0; + lastSkippedZoneIdx = -1; + }); + // Context menu function createMenuItem(title, callback) { const item = document.createElement("li"); item.setAttribute("role", "menuitem"); const button = document.createElement("button"); button.classList.add("main-contextMenu-menuItemButton"); button.textContent = title; - button.onclick = () => { + button.onclick = (e) => { + e.stopPropagation(); contextMenu.hidden = true; + moveSubmenu.hidden = true; callback?.(); }; item.append(button); return item; } - const startBtn = createMenuItem("Set start", () => { - start = mouseOnBarPercent; - if (end === null || start > end) { - end = 0.99; + const startBtn = createMenuItem("Set song start", () => { + if (end !== null && mouseOnBarPercent >= end) { + Spicetify.showNotification("Song start must be before song end"); + return; } + start = mouseOnBarPercent; drawOnBar(); + saveState(); }); - const endBtn = createMenuItem("Set end", () => { + const endBtn = createMenuItem("Set song end", () => { + if (start !== null && mouseOnBarPercent <= start) { + Spicetify.showNotification("Song end must be after song start"); + return; + } end = mouseOnBarPercent; - if (start === null || end < start) { - start = 0; + drawOnBar(); + saveState(); + }); + + const divider1 = document.createElement("li"); + divider1.style.cssText = "border-top:1px solid rgba(255,255,255,0.2);margin:4px 0;list-style:none;"; + + const skipStartBtn = createMenuItem("Set section skip start", () => { + if (pendingSkipStart !== null) { + pendingSkipStart = null; + } else { + pendingSkipStart = mouseOnBarPercent; } + drawSkipMarkers(); + }); + const skipEndBtn = createMenuItem("Set section skip end", () => { + if (pendingSkipStart === null) { + Spicetify.showNotification("No section skip start selected!"); + return; + } + const s = Math.min(pendingSkipStart, mouseOnBarPercent); + const e = Math.max(pendingSkipStart, mouseOnBarPercent); + if (e > s) { + if (skipZones.length < 10) { + skipZones.push({ start: s, end: e }); + saveState(); + drawSkipMarkers(); + } else { + Spicetify.showNotification("Maximum 10 skip zones reached"); + } + } + pendingSkipStart = null; + }); + const clearSkipsBtn = createMenuItem("Clear section skips", () => { + skipZones = []; + pendingSkipStart = null; + saveState(); + drawSkipMarkers(); + }); + + const resetMarkersBtn = createMenuItem("Reset song start/end", () => { + start = null; + end = null; drawOnBar(); + saveState(); }); - const resetBtn = createMenuItem("Reset", reset); + + const divider2 = document.createElement("li"); + divider2.style.cssText = "border-top:1px solid rgba(255,255,255,0.2);margin:4px 0;list-style:none;"; + divider2.hidden = true; + let activeZoneIndex = -1; + + // Move ▶ button — wired up in Task 2 after moveSubmenu is created + const moveBtnItem = document.createElement("li"); + moveBtnItem.setAttribute("role", "menuitem"); + const moveBtnEl = document.createElement("button"); + moveBtnEl.classList.add("main-contextMenu-menuItemButton"); + moveBtnEl.textContent = "Move \u25B6"; + moveBtnItem.append(moveBtnEl); + moveBtnItem.hidden = true; + + // Dynamic remove button — label/callback set by setupActiveMarker + const removeActiveBtn = document.createElement("li"); + removeActiveBtn.setAttribute("role", "menuitem"); + const removeActiveBtnEl = document.createElement("button"); + removeActiveBtnEl.classList.add("main-contextMenu-menuItemButton"); + removeActiveBtnEl.textContent = "Remove section"; + removeActiveBtn.append(removeActiveBtnEl); + removeActiveBtn.hidden = true; const contextMenu = document.createElement("div"); contextMenu.id = "loopy-context-menu"; contextMenu.innerHTML = ``; - contextMenu.style.position = "absolute"; - contextMenu.firstElementChild.append(startBtn, endBtn, resetBtn); + contextMenu.firstElementChild.append( + startBtn, + endBtn, + resetMarkersBtn, + divider1, + skipStartBtn, + skipEndBtn, + clearSkipsBtn, + divider2, + moveBtnItem, + removeActiveBtn + ); document.body.append(contextMenu); - const { height: contextMenuHeight } = contextMenu.getBoundingClientRect(); contextMenu.hidden = true; - window.addEventListener("click", () => { - contextMenu.hidden = true; - }); - progressContainer.oncontextmenu = (event) => { - const { x, width } = bar.getBoundingClientRect(); - mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); - contextMenu.style.transform = `translate(${event.clientX}px,${event.clientY - contextMenuHeight}px)`; - contextMenu.hidden = false; - event.preventDefault(); + function showMoveSubmenu() { + const rect = moveBtnEl.getBoundingClientRect(); + moveSubmenu.style.left = "-9999px"; + moveSubmenu.style.top = "0px"; + moveSubmenu.hidden = false; + const { height, width } = moveSubmenu.getBoundingClientRect(); + moveSubmenu.style.left = Math.min(rect.right + 2, window.innerWidth - width - 4) + "px"; + moveSubmenu.style.top = Math.max(0, Math.min(rect.top, window.innerHeight - height - 4)) + "px"; + } + + let moveHideTimer = null; + function scheduleMoveHide() { + cancelMoveHide(); + moveHideTimer = setTimeout(() => { + moveSubmenu.hidden = true; + moveHideTimer = null; + }, 150); + } + function cancelMoveHide() { + if (moveHideTimer) { + clearTimeout(moveHideTimer); + moveHideTimer = null; + } + } + + moveBtnEl.onclick = (e) => { + e.stopPropagation(); + cancelMoveHide(); + showMoveSubmenu(); }; + moveBtnItem.addEventListener("mouseenter", () => { + cancelMoveHide(); + showMoveSubmenu(); + }); + moveBtnItem.addEventListener("mouseleave", () => scheduleMoveHide()); + moveSubmenu.addEventListener("mouseenter", () => cancelMoveHide()); + moveSubmenu.addEventListener("mouseleave", () => scheduleMoveHide()); + + // Close menus on outside click + window.addEventListener("click", (e) => { + if (!contextMenu.contains(e.target) && !moveSubmenu.contains(e.target)) { + contextMenu.hidden = true; + moveSubmenu.hidden = true; + } + }); + + // Single capture-phase handler at document level — survives React re-renders + document.addEventListener( + "contextmenu", + (event) => { + const target = event.target; + + // [ song start marker + if (target.id === "loopy-loop-start") { + event.preventDefault(); + event.stopPropagation(); + mouseOnBarPercent = start ?? 0; + setupActiveMarker("start"); + openContextMenu(event.clientX, event.clientY); + return; + } + // ] song end marker + if (target.id === "loopy-loop-end") { + event.preventDefault(); + event.stopPropagation(); + mouseOnBarPercent = end ?? 1; + setupActiveMarker("end"); + openContextMenu(event.clientX, event.clientY); + return; + } + // { or } skip marker + if (target.classList?.contains("loopy-skip-marker") && target.getAttribute("data-zone-index") !== null) { + event.preventDefault(); + event.stopPropagation(); + const zIdx = parseInt(target.getAttribute("data-zone-index"), 10); + if (!Number.isFinite(zIdx) || zIdx < 0 || zIdx >= skipZones.length) return; + const side = target.getAttribute("data-zone-side") === "end" ? "zoneEnd" : "zoneStart"; + const smBar = getBar(); + if (smBar) { + const { x, width } = smBar.getBoundingClientRect(); + mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); + } + setupActiveMarker(side, zIdx); + openContextMenu(event.clientX, event.clientY); + return; + } + + // Progress bar area + const currentProgressContainer = document.querySelector(".playback-progressbar-container"); + if (!currentProgressContainer?.contains(target)) return; + event.preventDefault(); + event.stopPropagation(); + + const currentBar = currentProgressContainer.querySelector('input[type="range"]')?.closest("label")?.nextElementSibling; + if (!currentBar) return; + + const { x, width } = currentBar.getBoundingClientRect(); + mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); + + const hitZone = skipZones.findIndex((z) => mouseOnBarPercent > z.start && mouseOnBarPercent < z.end); + setupActiveMarker(hitZone >= 0 ? "zone" : null, hitZone); + openContextMenu(event.clientX, event.clientY); + }, + true + ); // capture phase + + // Load state for the currently playing song on startup. + // Retry until the player has track data (uri may be null immediately after init). + function tryLoadInitialState(attemptsLeft) { + if (Spicetify.Player.data?.item?.uri) { + loadState(); + drawOnBar(); + drawSkipMarkers(); + } else if (attemptsLeft > 0) { + setTimeout(() => tryLoadInitialState(attemptsLeft - 1), 200); + } + } + tryLoadInitialState(10); + + // Toolbar button + try { + const markerIcon = ``; + const toolbarBtn = new Spicetify.Playbar.Button("Loopy Loop", markerIcon, () => { + mouseOnBarPercent = Spicetify.Player.getProgressPercent(); + setupActiveMarker(null, -1); + const rect = toolbarBtn.element.getBoundingClientRect(); + openContextMenu(rect.left, rect.top); + }); + toolbarBtn.element.addEventListener("click", (e) => e.stopPropagation()); + } catch (_) {} })();