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