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) {