From a304a22079280d03fd3a892617e30dd33cae6175 Mon Sep 17 00:00:00 2001 From: 3xy <77691604+3xyy@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:26:59 -0700 Subject: [PATCH 1/8] Added persistency, skip sections, more --- Extensions/loopyLoop.js | 328 ++++++++++++++++++++++++++++++++++------ 1 file changed, 280 insertions(+), 48 deletions(-) diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js index 228239cd76..af3af0b682 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. /// @@ -22,6 +23,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: 15; + padding: 2px 6px; +} +#loopy-context-menu, #loopy-remove-menu { + position: fixed; + z-index: 2147483647; } `; @@ -34,56 +52,174 @@ startMark.style.position = endMark.style.position = "absolute"; startMark.hidden = endMark.hidden = true; - bar.append(style); - bar.append(startMark); - bar.append(endMark); + bar.append(style, startMark, endMark); let start = null; let end = null; let mouseOnBarPercent = 0.0; + let skipZones = []; + let pendingSkipStart = null; + let lastSkipSeek = 0; + let lastNextCall = 0; + let seekStartPendingUri = null; + let lastStartEnforce = 0; function drawOnBar() { - if (start === null && end === null) { - startMark.hidden = endMark.hidden = true; - return; + 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() { + bar.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); + + const e = document.createElement("div"); + e.className = "loopy-skip-marker"; + e.innerText = "}"; + e.style.left = `${zone.end * 100}%`; + e.dataset.zoneIndex = String(index); + + bar.append(s, e); + }); + + 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"; + bar.append(p); } - startMark.hidden = endMark.hidden = false; - startMark.style.left = `${start * 100}%`; - endMark.style.left = `${end * 100}%`; } - function reset() { - start = null; - end = null; - drawOnBar(); + + 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; 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 (_) {} + } + + // 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"; } - let debouncing = 0; + // Small remove-point popup + const removeMenu = document.createElement("div"); + removeMenu.id = "loopy-remove-menu"; + removeMenu.innerHTML = ``; + removeMenu.hidden = true; + document.body.append(removeMenu); + + function showRemoveMenu(x, y, type, zoneIndex) { + removeMenu.firstElementChild.innerHTML = ""; + const labels = { zone: "Remove this zone", start: "Remove song start", end: "Remove song end" }; + + const item = document.createElement("li"); + item.setAttribute("role", "menuitem"); + const btn = document.createElement("button"); + btn.classList.add("main-contextMenu-menuItemButton"); + btn.textContent = labels[type]; + btn.onclick = (e) => { + e.stopPropagation(); + if (type === "zone") { skipZones.splice(zoneIndex, 1); drawSkipMarkers(); } + else if (type === "start") { start = null; drawOnBar(); } + else if (type === "end") { end = null; drawOnBar(); } + saveState(); + removeMenu.hidden = true; + }; + item.append(btn); + removeMenu.firstElementChild.append(item); + openMenu(removeMenu, x, y); + } + + // Skip zone seeking only — no loop-back behavior Spicetify.Player.addEventListener("onprogress", (event) => { - if (start != null && end != null) { - if (debouncing) { - if (event.timeStamp - debouncing > 1000) { - debouncing = 0; - } + 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; + seekStartPendingUri = null; + } + + // Song start enforcement: seek to [ if playback is before it (covers song load + manual scrub) + if (start !== null && percent < start) { + if (event.timeStamp - lastStartEnforce > 500) { + lastStartEnforce = event.timeStamp; Spicetify.Player.seek(start); - return; + } + return; + } + + // Song end enforcement: advance to next track when playback reaches ] + if (end !== null && percent >= end) { + if (event.timeStamp - lastNextCall > 2000) { + lastNextCall = event.timeStamp; + seekStartPendingUri = Spicetify.Player.data?.item?.uri ?? null; + Spicetify.Player.next(); + } + return; + } + + // Skip zone seeking + if (skipZones.length > 0) { + for (const zone of skipZones) { + if (percent >= zone.start && percent < zone.end) { + if (event.timeStamp - lastSkipSeek > 1000) { + lastSkipSeek = event.timeStamp; + Spicetify.Player.seek(zone.end); + } + break; + } } } }); - Spicetify.Player.addEventListener("songchange", reset); + Spicetify.Player.addEventListener("songchange", () => { + loadState(); drawOnBar(); drawSkipMarkers(); + }); + // 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; callback?.(); }; @@ -91,39 +227,135 @@ return item; } - const startBtn = createMenuItem("Set start", () => { + const startBtn = createMenuItem("Set song start", () => { start = mouseOnBarPercent; - if (end === null || start > end) { - end = 0.99; - } - drawOnBar(); + if (end === null || start > end) end = 0.99; + drawOnBar(); saveState(); }); - const endBtn = createMenuItem("Set end", () => { + const endBtn = createMenuItem("Set song end", () => { end = mouseOnBarPercent; - if (start === null || end < start) { - start = 0; + 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", () => { + 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 && skipZones.length < 10) { + skipZones.push({ start: s, end: e }); + saveState(); drawSkipMarkers(); + } + 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 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; + const removeSectionBtn = createMenuItem("Remove section", () => { + if (activeZoneIndex >= 0) { + skipZones.splice(activeZoneIndex, 1); + saveState(); drawSkipMarkers(); activeZoneIndex = -1; } - drawOnBar(); }); - const resetBtn = createMenuItem("Reset", reset); + removeSectionBtn.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, removeSectionBtn + ); document.body.append(contextMenu); - const { height: contextMenuHeight } = contextMenu.getBoundingClientRect(); contextMenu.hidden = true; - window.addEventListener("click", () => { - contextMenu.hidden = true; + + // Close menus on outside click + window.addEventListener("click", (e) => { + if (!contextMenu.contains(e.target)) contextMenu.hidden = true; + if (!removeMenu.contains(e.target)) removeMenu.hidden = true; }); - progressContainer.oncontextmenu = (event) => { - const { x, width } = bar.getBoundingClientRect(); + // Single capture-phase handler at document level — survives React re-renders + document.addEventListener("contextmenu", (event) => { + const target = event.target; + + // Our own markers — show remove popup + if (target.id === "loopy-loop-start") { + event.preventDefault(); event.stopPropagation(); + showRemoveMenu(event.clientX, event.clientY, "start"); + return; + } + if (target.id === "loopy-loop-end") { + event.preventDefault(); event.stopPropagation(); + showRemoveMenu(event.clientX, event.clientY, "end"); + return; + } + if (target.classList?.contains("loopy-skip-marker") && target.dataset.zoneIndex !== undefined) { + event.preventDefault(); event.stopPropagation(); + activeZoneIndex = parseInt(target.dataset.zoneIndex); + divider2.hidden = false; + removeSectionBtn.hidden = false; + const smContainer = document.querySelector(".playback-progressbar-container"); + const smBar = smContainer?.querySelector('input[type="range"]')?.closest("label")?.nextElementSibling; + if (smBar) { + const { x, width } = smBar.getBoundingClientRect(); + mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); + } + openMenu(contextMenu, event.clientX, event.clientY); + return; + } + + // Progress bar area — show main context menu + 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)); - contextMenu.style.transform = `translate(${event.clientX}px,${event.clientY - contextMenuHeight}px)`; - contextMenu.hidden = false; - event.preventDefault(); - }; + + activeZoneIndex = skipZones.findIndex(z => mouseOnBarPercent > z.start && mouseOnBarPercent < z.end); + const inZone = activeZoneIndex >= 0; + divider2.hidden = !inZone; + removeSectionBtn.hidden = !inZone; + + openMenu(contextMenu, event.clientX, event.clientY); + }, true); // capture phase + + // Toolbar button + try { + const markerIcon = ``; + const toolbarBtn = new Spicetify.Playbar.Button("Loopy Loop", markerIcon, () => { + mouseOnBarPercent = Spicetify.Player.getProgressPercent(); + activeZoneIndex = -1; divider2.hidden = true; removeSectionBtn.hidden = true; + const rect = toolbarBtn.element.getBoundingClientRect(); + openMenu(contextMenu, rect.left, rect.top); + }); + toolbarBtn.element.addEventListener("click", (e) => e.stopPropagation()); + } catch (_) {} })(); From 0c4881789ab1367671960547cb8b6e26467b8a50 Mon Sep 17 00:00:00 2001 From: 3xy <77691604+3xyy@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:18:49 -0700 Subject: [PATCH 2/8] Bug fixes, polishing --- Extensions/loopyLoop.js | 235 +++++++++++++++++++++++++++++++--------- 1 file changed, 181 insertions(+), 54 deletions(-) diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js index af3af0b682..78d5d591c7 100644 --- a/Extensions/loopyLoop.js +++ b/Extensions/loopyLoop.js @@ -16,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 { @@ -34,10 +39,10 @@ top: -7px; color: #e74c3c; cursor: context-menu; - z-index: 15; + z-index: 20; padding: 2px 6px; } -#loopy-context-menu, #loopy-remove-menu { +#loopy-context-menu, #loopy-move-submenu { position: fixed; z-index: 2147483647; } @@ -52,7 +57,8 @@ startMark.style.position = endMark.style.position = "absolute"; startMark.hidden = endMark.hidden = true; - bar.append(style, startMark, endMark); + document.head.append(style); + bar.append(startMark, endMark); let start = null; let end = null; @@ -63,8 +69,16 @@ 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() { + 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}%`; @@ -72,21 +86,25 @@ } function drawSkipMarkers() { - bar.querySelectorAll(".loopy-skip-marker").forEach(el => el.remove()); + 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"; - bar.append(s, e); + currentBar.append(e, s); // { appended after } so { sits higher in stacking order }); if (pendingSkipStart !== null) { @@ -95,7 +113,7 @@ p.innerText = "{"; p.style.left = `${pendingSkipStart * 100}%`; p.style.opacity = "0.4"; - bar.append(p); + currentBar.append(p); } } @@ -130,34 +148,82 @@ menu.style.top = Math.max(0, y - height) + "px"; } - // Small remove-point popup - const removeMenu = document.createElement("div"); - removeMenu.id = "loopy-remove-menu"; - removeMenu.innerHTML = ``; - removeMenu.hidden = true; - document.body.append(removeMenu); + // 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; + }; + } + } - function showRemoveMenu(x, y, type, zoneIndex) { - removeMenu.firstElementChild.innerHTML = ""; - const labels = { zone: "Remove this zone", start: "Remove song start", end: "Remove song end" }; - const item = document.createElement("li"); - item.setAttribute("role", "menuitem"); + // 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 : 1, start + delta)); + drawOnBar(); + } else if (activeMarkerType === "end") { + end = Math.max(start !== null ? start : 0, Math.min(1, end + delta)); + drawOnBar(); + } else if (activeMarkerType === "zoneStart") { + skipZones[activeZoneIndex].start = Math.max(0, Math.min(skipZones[activeZoneIndex].end, skipZones[activeZoneIndex].start + delta)); + drawSkipMarkers(); + } else if (activeMarkerType === "zoneEnd") { + skipZones[activeZoneIndex].end = Math.max(skipZones[activeZoneIndex].start, 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 = labels[type]; - btn.onclick = (e) => { - e.stopPropagation(); - if (type === "zone") { skipZones.splice(zoneIndex, 1); drawSkipMarkers(); } - else if (type === "start") { start = null; drawOnBar(); } - else if (type === "end") { end = null; drawOnBar(); } - saveState(); - removeMenu.hidden = true; - }; - item.append(btn); - removeMenu.firstElementChild.append(item); - openMenu(removeMenu, x, y); - } + 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) => { @@ -174,8 +240,30 @@ 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; + if (prevProgressPercent > threeSecFrac && percent < 0.02) { + if (prevPressedAt > 0 && event.timeStamp - prevPressedAt < 1500) { + // Second press within 1.5s — go to previous song + prevPressedAt = 0; + prevProgressPercent = percent; + navigatingBack = true; + Spicetify.Player.back(); + return; + } else { + // First press — go to [ (or stay at 0 if no start set) + prevPressedAt = event.timeStamp; + 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 (event.timeStamp - lastStartEnforce > 500) { lastStartEnforce = event.timeStamp; Spicetify.Player.seek(start); @@ -208,7 +296,10 @@ }); Spicetify.Player.addEventListener("songchange", () => { + navigatingBack = false; loadState(); drawOnBar(); drawSkipMarkers(); + prevProgressPercent = -1; prevPressedAt = 0; + lastStartEnforce = 0; lastNextCall = 0; lastSkipSeek = 0; }); // Context menu @@ -221,6 +312,7 @@ button.onclick = (e) => { e.stopPropagation(); contextMenu.hidden = true; + moveSubmenu.hidden = true; callback?.(); }; item.append(button); @@ -272,13 +364,24 @@ 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; - const removeSectionBtn = createMenuItem("Remove section", () => { - if (activeZoneIndex >= 0) { - skipZones.splice(activeZoneIndex, 1); - saveState(); drawSkipMarkers(); activeZoneIndex = -1; - } - }); - removeSectionBtn.hidden = true; + + // 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"; @@ -286,48 +389,75 @@ contextMenu.firstElementChild.append( startBtn, endBtn, resetMarkersBtn, divider1, skipStartBtn, skipEndBtn, clearSkipsBtn, - divider2, removeSectionBtn + divider2, moveBtnItem, removeActiveBtn ); document.body.append(contextMenu); contextMenu.hidden = true; + 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() { moveHideTimer = setTimeout(() => { moveSubmenu.hidden = true; }, 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)) contextMenu.hidden = true; - if (!removeMenu.contains(e.target)) removeMenu.hidden = true; + 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; - // Our own markers — show remove popup + // [ song start marker if (target.id === "loopy-loop-start") { event.preventDefault(); event.stopPropagation(); - showRemoveMenu(event.clientX, event.clientY, "start"); + mouseOnBarPercent = start ?? 0; + setupActiveMarker("start"); + openMenu(contextMenu, event.clientX, event.clientY); return; } + // ] song end marker if (target.id === "loopy-loop-end") { event.preventDefault(); event.stopPropagation(); - showRemoveMenu(event.clientX, event.clientY, "end"); + mouseOnBarPercent = end ?? 1; + setupActiveMarker("end"); + openMenu(contextMenu, event.clientX, event.clientY); return; } - if (target.classList?.contains("loopy-skip-marker") && target.dataset.zoneIndex !== undefined) { + // { or } skip marker + if (target.classList?.contains("loopy-skip-marker") && target.getAttribute("data-zone-index") !== null) { event.preventDefault(); event.stopPropagation(); - activeZoneIndex = parseInt(target.dataset.zoneIndex); - divider2.hidden = false; - removeSectionBtn.hidden = false; + const zIdx = parseInt(target.getAttribute("data-zone-index")); + const side = target.getAttribute("data-zone-side") === "end" ? "zoneEnd" : "zoneStart"; const smContainer = document.querySelector(".playback-progressbar-container"); const smBar = smContainer?.querySelector('input[type="range"]')?.closest("label")?.nextElementSibling; if (smBar) { const { x, width } = smBar.getBoundingClientRect(); mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); } + setupActiveMarker(side, zIdx); openMenu(contextMenu, event.clientX, event.clientY); return; } - // Progress bar area — show main context menu + // Progress bar area const currentProgressContainer = document.querySelector(".playback-progressbar-container"); if (!currentProgressContainer?.contains(target)) return; event.preventDefault(); event.stopPropagation(); @@ -339,11 +469,8 @@ const { x, width } = currentBar.getBoundingClientRect(); mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); - activeZoneIndex = skipZones.findIndex(z => mouseOnBarPercent > z.start && mouseOnBarPercent < z.end); - const inZone = activeZoneIndex >= 0; - divider2.hidden = !inZone; - removeSectionBtn.hidden = !inZone; - + const hitZone = skipZones.findIndex(z => mouseOnBarPercent > z.start && mouseOnBarPercent < z.end); + setupActiveMarker(hitZone >= 0 ? "zone" : null, hitZone); openMenu(contextMenu, event.clientX, event.clientY); }, true); // capture phase @@ -352,7 +479,7 @@ const markerIcon = ``; const toolbarBtn = new Spicetify.Playbar.Button("Loopy Loop", markerIcon, () => { mouseOnBarPercent = Spicetify.Player.getProgressPercent(); - activeZoneIndex = -1; divider2.hidden = true; removeSectionBtn.hidden = true; + setupActiveMarker(null, -1); const rect = toolbarBtn.element.getBoundingClientRect(); openMenu(contextMenu, rect.left, rect.top); }); From 383a2ed11bce2a5e45a23db00f57de8f9ae576cf Mon Sep 17 00:00:00 2001 From: 3xy <77691604+3xyy@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:22:15 -0700 Subject: [PATCH 3/8] Final commit, polishing --- Extensions/loopyLoop.js | 46 +++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js index 78d5d591c7..854fa11618 100644 --- a/Extensions/loopyLoop.js +++ b/Extensions/loopyLoop.js @@ -148,6 +148,12 @@ 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; @@ -199,16 +205,16 @@ if (!durationMs) return; const delta = (deltaSeconds * 1000) / durationMs; if (activeMarkerType === "start") { - start = Math.max(0, Math.min(end !== null ? end : 1, start + delta)); + 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 : 0, Math.min(1, end + delta)); + end = Math.max(start !== null ? start + 1e-6 : 0, Math.min(1, end + delta)); drawOnBar(); } else if (activeMarkerType === "zoneStart") { - skipZones[activeZoneIndex].start = Math.max(0, Math.min(skipZones[activeZoneIndex].end, skipZones[activeZoneIndex].start + delta)); + skipZones[activeZoneIndex].start = Math.max(0, Math.min(skipZones[activeZoneIndex].end - 1e-6, skipZones[activeZoneIndex].start + delta)); drawSkipMarkers(); } else if (activeMarkerType === "zoneEnd") { - skipZones[activeZoneIndex].end = Math.max(skipZones[activeZoneIndex].start, Math.min(1, skipZones[activeZoneIndex].end + delta)); + skipZones[activeZoneIndex].end = Math.max(skipZones[activeZoneIndex].start + 1e-6, Math.min(1, skipZones[activeZoneIndex].end + delta)); drawSkipMarkers(); } saveState(); @@ -243,12 +249,14 @@ // 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; - if (prevProgressPercent > threeSecFrac && percent < 0.02) { + const nearZeroFrac = durationMs > 0 ? 1500 / durationMs : 0.01; + if (prevProgressPercent > threeSecFrac && percent < nearZeroFrac) { if (prevPressedAt > 0 && event.timeStamp - 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 { @@ -297,6 +305,8 @@ 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; @@ -320,13 +330,19 @@ } const startBtn = createMenuItem("Set song start", () => { + if (end !== null && mouseOnBarPercent >= end) { + Spicetify.showNotification("Song start must be before song end"); + return; + } start = mouseOnBarPercent; - if (end === null || start > end) end = 0.99; drawOnBar(); saveState(); }); 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(); }); @@ -334,7 +350,11 @@ 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", () => { - pendingSkipStart = mouseOnBarPercent; + if (pendingSkipStart !== null) { + pendingSkipStart = null; + } else { + pendingSkipStart = mouseOnBarPercent; + } drawSkipMarkers(); }); const skipEndBtn = createMenuItem("Set section skip end", () => { @@ -430,7 +450,7 @@ event.preventDefault(); event.stopPropagation(); mouseOnBarPercent = start ?? 0; setupActiveMarker("start"); - openMenu(contextMenu, event.clientX, event.clientY); + openContextMenu(event.clientX, event.clientY); return; } // ] song end marker @@ -438,7 +458,7 @@ event.preventDefault(); event.stopPropagation(); mouseOnBarPercent = end ?? 1; setupActiveMarker("end"); - openMenu(contextMenu, event.clientX, event.clientY); + openContextMenu(event.clientX, event.clientY); return; } // { or } skip marker @@ -453,7 +473,7 @@ mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); } setupActiveMarker(side, zIdx); - openMenu(contextMenu, event.clientX, event.clientY); + openContextMenu(event.clientX, event.clientY); return; } @@ -471,7 +491,7 @@ const hitZone = skipZones.findIndex(z => mouseOnBarPercent > z.start && mouseOnBarPercent < z.end); setupActiveMarker(hitZone >= 0 ? "zone" : null, hitZone); - openMenu(contextMenu, event.clientX, event.clientY); + openContextMenu(event.clientX, event.clientY); }, true); // capture phase // Toolbar button @@ -481,7 +501,7 @@ mouseOnBarPercent = Spicetify.Player.getProgressPercent(); setupActiveMarker(null, -1); const rect = toolbarBtn.element.getBoundingClientRect(); - openMenu(contextMenu, rect.left, rect.top); + openContextMenu(rect.left, rect.top); }); toolbarBtn.element.addEventListener("click", (e) => e.stopPropagation()); } catch (_) {} From b7ce47ef5a5aa01084024c1781b79f0d40849e83 Mon Sep 17 00:00:00 2001 From: 3xy <77691604+3xyy@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:42:28 -0700 Subject: [PATCH 4/8] Persistence across sessions --- Extensions/loopyLoop.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js index 854fa11618..dc641cd254 100644 --- a/Extensions/loopyLoop.js +++ b/Extensions/loopyLoop.js @@ -494,6 +494,9 @@ openContextMenu(event.clientX, event.clientY); }, true); // capture phase + // Load state for the currently playing song on startup + loadState(); drawOnBar(); drawSkipMarkers(); + // Toolbar button try { const markerIcon = ``; From 1585ea07418f03f93effdebc41dc69e21ac19baf Mon Sep 17 00:00:00 2001 From: 3xy <77691604+3xyy@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:02:21 -0700 Subject: [PATCH 5/8] Address CodeRabbit review: bounds check, skip throttle, zone limit notification, formatting --- Extensions/loopyLoop.js | 239 ++++++++++++++++++++++++++-------------- 1 file changed, 156 insertions(+), 83 deletions(-) diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js index dc641cd254..5df90a4ce3 100644 --- a/Extensions/loopyLoop.js +++ b/Extensions/loopyLoop.js @@ -66,6 +66,7 @@ let skipZones = []; let pendingSkipStart = null; let lastSkipSeek = 0; + let lastSkippedZoneIdx = -1; let lastNextCall = 0; let seekStartPendingUri = null; let lastStartEnforce = 0; @@ -88,7 +89,7 @@ function drawSkipMarkers() { const currentBar = getBar() ?? bar; if (!currentBar) return; - currentBar.querySelectorAll(".loopy-skip-marker").forEach(el => el.remove()); + currentBar.querySelectorAll(".loopy-skip-marker").forEach((el) => el.remove()); skipZones.forEach((zone, index) => { const s = document.createElement("div"); s.className = "loopy-skip-marker"; @@ -125,7 +126,10 @@ function loadState() { const uri = Spicetify.Player.data?.item?.uri; - start = null; end = null; skipZones = []; pendingSkipStart = null; + start = null; + end = null; + skipZones = []; + pendingSkipStart = null; if (!uri) return; try { const saved = Spicetify.LocalStorage.get(`loopyLoop:${uri}`); @@ -149,8 +153,7 @@ } function openContextMenu(x, y) { - skipStartBtn.querySelector("button").textContent = - pendingSkipStart !== null ? "Cancel skip start" : "Set section skip start"; + skipStartBtn.querySelector("button").textContent = pendingSkipStart !== null ? "Cancel skip start" : "Set section skip start"; openMenu(contextMenu, x, y); } @@ -169,15 +172,21 @@ removeBtn.textContent = "Remove song start"; removeBtn.onclick = (ev) => { ev.stopPropagation(); - start = null; drawOnBar(); saveState(); - contextMenu.hidden = true; moveSubmenu.hidden = true; + 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; + end = null; + drawOnBar(); + saveState(); + contextMenu.hidden = true; + moveSubmenu.hidden = true; }; } else { removeBtn.textContent = "Remove section"; @@ -185,14 +194,16 @@ ev.stopPropagation(); if (activeZoneIndex >= 0) { skipZones.splice(activeZoneIndex, 1); - saveState(); drawSkipMarkers(); activeZoneIndex = -1; + saveState(); + drawSkipMarkers(); + activeZoneIndex = -1; } - contextMenu.hidden = true; moveSubmenu.hidden = true; + contextMenu.hidden = true; + moveSubmenu.hidden = true; }; } } - // Move submenu const moveSubmenu = document.createElement("div"); moveSubmenu.id = "loopy-move-submenu"; @@ -211,22 +222,27 @@ 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 => { + [-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); }; + btn.onclick = (e) => { + e.stopPropagation(); + applyMoveAdjustment(delta); + }; li.append(btn); moveSubmenu.firstElementChild.append(li); }); @@ -256,7 +272,9 @@ prevPressedAt = 0; prevProgressPercent = percent; navigatingBack = true; - setTimeout(() => { navigatingBack = false; }, 2000); + setTimeout(() => { + navigatingBack = false; + }, 2000); Spicetify.Player.back(); return; } else { @@ -291,15 +309,20 @@ // Skip zone seeking if (skipZones.length > 0) { - for (const zone of skipZones) { + let inZone = false; + for (let i = 0; i < skipZones.length; i++) { + const zone = skipZones[i]; if (percent >= zone.start && percent < zone.end) { - if (event.timeStamp - lastSkipSeek > 1000) { + inZone = true; + if (i !== lastSkippedZoneIdx || event.timeStamp - lastSkipSeek > 500) { lastSkipSeek = event.timeStamp; + lastSkippedZoneIdx = i; Spicetify.Player.seek(zone.end); } break; } } + if (!inZone) lastSkippedZoneIdx = -1; } }); @@ -307,9 +330,15 @@ 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; + loadState(); + drawOnBar(); + drawSkipMarkers(); + prevProgressPercent = -1; + prevPressedAt = 0; + lastStartEnforce = 0; + lastNextCall = 0; + lastSkipSeek = 0; + lastSkippedZoneIdx = -1; }); // Context menu @@ -335,7 +364,8 @@ return; } start = mouseOnBarPercent; - drawOnBar(); saveState(); + drawOnBar(); + saveState(); }); const endBtn = createMenuItem("Set song end", () => { if (start !== null && mouseOnBarPercent <= start) { @@ -343,7 +373,8 @@ return; } end = mouseOnBarPercent; - drawOnBar(); saveState(); + drawOnBar(); + saveState(); }); const divider1 = document.createElement("li"); @@ -364,20 +395,29 @@ } const s = Math.min(pendingSkipStart, mouseOnBarPercent); const e = Math.max(pendingSkipStart, mouseOnBarPercent); - if (e > s && skipZones.length < 10) { - skipZones.push({ start: s, end: e }); - saveState(); drawSkipMarkers(); + 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(); + skipZones = []; + pendingSkipStart = null; + saveState(); + drawSkipMarkers(); }); const resetMarkersBtn = createMenuItem("Reset song start/end", () => { - start = null; end = null; - drawOnBar(); saveState(); + start = null; + end = null; + drawOnBar(); + saveState(); }); const divider2 = document.createElement("li"); @@ -407,9 +447,16 @@ contextMenu.id = "loopy-context-menu"; contextMenu.innerHTML = ``; contextMenu.firstElementChild.append( - startBtn, endBtn, resetMarkersBtn, divider1, - skipStartBtn, skipEndBtn, clearSkipsBtn, - divider2, moveBtnItem, removeActiveBtn + startBtn, + endBtn, + resetMarkersBtn, + divider1, + skipStartBtn, + skipEndBtn, + clearSkipsBtn, + divider2, + moveBtnItem, + removeActiveBtn ); document.body.append(contextMenu); contextMenu.hidden = true; @@ -425,11 +472,27 @@ } let moveHideTimer = null; - function scheduleMoveHide() { moveHideTimer = setTimeout(() => { moveSubmenu.hidden = true; }, 150); } - function cancelMoveHide() { if (moveHideTimer) { clearTimeout(moveHideTimer); moveHideTimer = null; } } + function scheduleMoveHide() { + moveHideTimer = setTimeout(() => { + moveSubmenu.hidden = true; + }, 150); + } + function cancelMoveHide() { + if (moveHideTimer) { + clearTimeout(moveHideTimer); + moveHideTimer = null; + } + } - moveBtnEl.onclick = (e) => { e.stopPropagation(); cancelMoveHide(); showMoveSubmenu(); }; - moveBtnItem.addEventListener("mouseenter", () => { cancelMoveHide(); showMoveSubmenu(); }); + moveBtnEl.onclick = (e) => { + e.stopPropagation(); + cancelMoveHide(); + showMoveSubmenu(); + }; + moveBtnItem.addEventListener("mouseenter", () => { + cancelMoveHide(); + showMoveSubmenu(); + }); moveBtnItem.addEventListener("mouseleave", () => scheduleMoveHide()); moveSubmenu.addEventListener("mouseenter", () => cancelMoveHide()); moveSubmenu.addEventListener("mouseleave", () => scheduleMoveHide()); @@ -437,65 +500,75 @@ // Close menus on outside click window.addEventListener("click", (e) => { if (!contextMenu.contains(e.target) && !moveSubmenu.contains(e.target)) { - contextMenu.hidden = true; moveSubmenu.hidden = true; + 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")); - const side = target.getAttribute("data-zone-side") === "end" ? "zoneEnd" : "zoneStart"; - const smContainer = document.querySelector(".playback-progressbar-container"); - const smBar = smContainer?.querySelector('input[type="range"]')?.closest("label")?.nextElementSibling; - if (smBar) { - const { x, width } = smBar.getBoundingClientRect(); - mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); + 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")); + const side = target.getAttribute("data-zone-side") === "end" ? "zoneEnd" : "zoneStart"; + const smContainer = document.querySelector(".playback-progressbar-container"); + const smBar = smContainer?.querySelector('input[type="range"]')?.closest("label")?.nextElementSibling; + 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; } - 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(); + // 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 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 { 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 + 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 - loadState(); drawOnBar(); drawSkipMarkers(); + loadState(); + drawOnBar(); + drawSkipMarkers(); // Toolbar button try { From d434dc897c2c8481766ff86cc06a1fdc781befd9 Mon Sep 17 00:00:00 2001 From: 3xy <77691604+3xyy@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:38:19 -0700 Subject: [PATCH 6/8] Use getBar() helper and explicit parseInt radix --- Extensions/loopyLoop.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js index 5df90a4ce3..de034fba91 100644 --- a/Extensions/loopyLoop.js +++ b/Extensions/loopyLoop.js @@ -533,10 +533,9 @@ 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")); + const zIdx = parseInt(target.getAttribute("data-zone-index"), 10); const side = target.getAttribute("data-zone-side") === "end" ? "zoneEnd" : "zoneStart"; - const smContainer = document.querySelector(".playback-progressbar-container"); - const smBar = smContainer?.querySelector('input[type="range"]')?.closest("label")?.nextElementSibling; + const smBar = getBar(); if (smBar) { const { x, width } = smBar.getBoundingClientRect(); mouseOnBarPercent = Math.max(0, Math.min(1, (event.clientX - x) / width)); From 3fdf5f880a3f9d5944abeef06201e45cf85d9b1f Mon Sep 17 00:00:00 2001 From: 3xy <77691604+3xyy@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:42:12 -0700 Subject: [PATCH 7/8] Fix startup marker load: retry until player has track data --- Extensions/loopyLoop.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js index de034fba91..bb18f9e7c1 100644 --- a/Extensions/loopyLoop.js +++ b/Extensions/loopyLoop.js @@ -564,10 +564,18 @@ true ); // capture phase - // Load state for the currently playing song on startup - loadState(); - drawOnBar(); - drawSkipMarkers(); + // 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 { From a9a0a9a34145229eda642b780e2f9cea9f2112c1 Mon Sep 17 00:00:00 2001 From: 3xy <77691604+3xyy@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:04:52 -0700 Subject: [PATCH 8/8] Fix timer race, zIdx guard, safe event.timeStamp in onprogress --- Extensions/loopyLoop.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Extensions/loopyLoop.js b/Extensions/loopyLoop.js index bb18f9e7c1..09396f3525 100644 --- a/Extensions/loopyLoop.js +++ b/Extensions/loopyLoop.js @@ -249,6 +249,7 @@ // 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 [ @@ -267,7 +268,7 @@ const threeSecFrac = durationMs > 0 ? 3000 / durationMs : 0.02; const nearZeroFrac = durationMs > 0 ? 1500 / durationMs : 0.01; if (prevProgressPercent > threeSecFrac && percent < nearZeroFrac) { - if (prevPressedAt > 0 && event.timeStamp - prevPressedAt < 1500) { + if (prevPressedAt > 0 && ts - prevPressedAt < 1500) { // Second press within 1.5s — go to previous song prevPressedAt = 0; prevProgressPercent = percent; @@ -279,7 +280,7 @@ return; } else { // First press — go to [ (or stay at 0 if no start set) - prevPressedAt = event.timeStamp; + prevPressedAt = ts; prevProgressPercent = percent; if (start !== null) Spicetify.Player.seek(start); return; @@ -290,8 +291,8 @@ // Song start enforcement: seek to [ if playback is before it (covers song load + manual scrub) if (start !== null && percent < start) { if (navigatingBack) return; - if (event.timeStamp - lastStartEnforce > 500) { - lastStartEnforce = event.timeStamp; + if (ts - lastStartEnforce > 500) { + lastStartEnforce = ts; Spicetify.Player.seek(start); } return; @@ -299,8 +300,8 @@ // Song end enforcement: advance to next track when playback reaches ] if (end !== null && percent >= end) { - if (event.timeStamp - lastNextCall > 2000) { - lastNextCall = event.timeStamp; + if (ts - lastNextCall > 2000) { + lastNextCall = ts; seekStartPendingUri = Spicetify.Player.data?.item?.uri ?? null; Spicetify.Player.next(); } @@ -314,8 +315,8 @@ const zone = skipZones[i]; if (percent >= zone.start && percent < zone.end) { inZone = true; - if (i !== lastSkippedZoneIdx || event.timeStamp - lastSkipSeek > 500) { - lastSkipSeek = event.timeStamp; + if (i !== lastSkippedZoneIdx || ts - lastSkipSeek > 500) { + lastSkipSeek = ts; lastSkippedZoneIdx = i; Spicetify.Player.seek(zone.end); } @@ -473,8 +474,10 @@ let moveHideTimer = null; function scheduleMoveHide() { + cancelMoveHide(); moveHideTimer = setTimeout(() => { moveSubmenu.hidden = true; + moveHideTimer = null; }, 150); } function cancelMoveHide() { @@ -534,6 +537,7 @@ 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) {