Multi-select and bulk delete songs on Sonauto editor
// ==UserScript==
// @name Sonauto Multi-Delete
// @namespace https://greasyfork.org/en/users/10118-drhouse
// @version 1.3
// @description Multi-select and bulk delete songs on Sonauto editor
// @match https://sonauto.ai/*
// @grant none
// @require https://code.jquery.com/jquery-3.7.1.min.js
// @author drhouse
// @license CC-BY-NC-SA-4.0
// @icon https://www.google.com/s2/favicons?sz=64&domain=sonauto.ai
// ==/UserScript==
(function () {
'use strict';
const $ = jQuery.noConflict(true);
// --- Simulate a real click (required for Radix UI components) ---
function realClick(el) {
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const opts = { bubbles: true, cancelable: true, clientX: x, clientY: y, pointerId: 1 };
el.dispatchEvent(new PointerEvent('pointerdown', opts));
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: x, clientY: y }));
el.dispatchEvent(new PointerEvent('pointerup', opts));
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX: x, clientY: y }));
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: x, clientY: y }));
}
// --- Resilient selectors (ID can be #editor-track-list OR #editor-track-list-and-details) ---
function getTrackListRoot() {
return document.querySelector('[id^="editor-track-list"]');
}
function getTrackContainer() {
const root = getTrackListRoot();
if (!root) return null;
// Find the visible section with song rows
const sections = root.querySelectorAll('section.contents');
for (const s of sections) {
const gc = s.querySelector('div.flex.flex-col.gap-2');
if (gc && gc.children.length > 0 && gc.querySelector('h3')) {
return gc;
}
}
return null;
}
// --- Styles ---
const style = document.createElement('style');
style.textContent = `
.sm-checkbox {
width: 18px;
height: 18px;
min-width: 18px;
accent-color: #ef4444;
cursor: pointer;
margin-right: 6px;
z-index: 10;
position: relative;
}
.sm-toolbar {
position: fixed;
bottom: 80px;
right: 30px;
z-index: 99999;
display: flex;
gap: 8px;
align-items: center;
background: #1a1a2e;
border: 1px solid #333;
border-radius: 12px;
padding: 8px 14px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
font-family: system-ui, sans-serif;
}
.sm-toolbar button {
padding: 6px 14px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: opacity 0.15s;
}
.sm-toolbar button:hover { opacity: 0.85; }
.sm-delete-btn {
background: #ef4444;
color: white;
}
.sm-delete-btn:disabled {
background: #555;
cursor: not-allowed;
opacity: 0.5 !important;
}
.sm-select-all-btn {
background: #3b82f6;
color: white;
}
.sm-deselect-btn {
background: #555;
color: #ccc;
}
.sm-count {
color: #ccc;
font-size: 13px;
min-width: 80px;
text-align: center;
}
.sm-progress {
color: #facc15;
font-size: 12px;
}
.sm-row-checked {
outline: 2px solid #ef4444 !important;
outline-offset: -2px;
}
`;
document.head.appendChild(style);
// --- Toolbar ---
const $toolbar = $(`
<div class="sm-toolbar">
<button class="sm-select-all-btn" id="sm-select-all">Select All</button>
<button class="sm-deselect-btn" id="sm-deselect">Deselect All</button>
<span class="sm-count" id="sm-count">0 selected</span>
<button class="sm-delete-btn" id="sm-delete-selected" disabled>Delete Selected</button>
<span class="sm-progress" id="sm-progress"></span>
</div>
`);
$('body').append($toolbar);
// --- Helpers ---
function getSongRows() {
const container = getTrackContainer();
return container ? Array.from(container.children) : [];
}
function updateCount() {
const checked = $('input.sm-checkbox:checked').length;
$('#sm-count').text(checked + ' selected');
$('#sm-delete-selected').prop('disabled', checked === 0);
}
// --- Inject checkboxes ---
function injectCheckboxes() {
const rows = getSongRows();
rows.forEach((row) => {
if (row.querySelector('.sm-checkbox')) return;
const h3 = row.querySelector('h3');
if (!h3) return;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'sm-checkbox';
cb.addEventListener('change', function () {
const container = this.closest('div.flex.flex-col.gap-2 > div');
if (container) {
container.classList.toggle('sm-row-checked', this.checked);
}
updateCount();
});
const flexGrow = h3.closest('.flex-grow');
if (flexGrow) {
flexGrow.parentElement.insertBefore(cb, flexGrow);
} else {
const titleContainer = h3.parentElement;
titleContainer.insertBefore(cb, titleContainer.firstChild);
}
});
}
// --- Delete logic ---
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function findEllipsisButton(row) {
const buttons = row.querySelectorAll('button');
for (const btn of buttons) {
const svg = btn.querySelector('svg.lucide-ellipsis-vertical');
if (svg && btn.parentElement && btn.parentElement.className.includes('@lg:contents')) {
return btn;
}
}
for (const btn of buttons) {
if (btn.querySelector('svg.lucide-ellipsis-vertical')) {
return btn;
}
}
return null;
}
function findDeleteMenuItem() {
const menuItems = document.querySelectorAll('[role="menuitem"]');
for (const item of menuItems) {
if (item.textContent.trim() === 'Delete' && item.classList.contains('text-red-500')) {
return item;
}
}
return null;
}
function findDeleteConfirmButton() {
const dialog = document.querySelector('[role="dialog"]');
if (!dialog) return null;
const buttons = dialog.querySelectorAll('button');
for (const btn of buttons) {
if (btn.textContent.trim() === 'Delete') {
return btn;
}
}
return null;
}
async function waitForElement(finder, timeout = 3000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const el = finder();
if (el) return el;
await sleep(100);
}
return null;
}
async function waitForElementGone(finder, timeout = 3000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const el = finder();
if (!el) return true;
await sleep(100);
}
return false;
}
async function deleteSong(row) {
row.scrollIntoView({ behavior: 'instant', block: 'center' });
await sleep(200);
const ellipsisBtn = findEllipsisButton(row);
if (!ellipsisBtn) {
console.warn('SM: No ellipsis button found');
return false;
}
realClick(ellipsisBtn);
const deleteMenuItem = await waitForElement(findDeleteMenuItem, 2000);
if (!deleteMenuItem) {
console.warn('SM: No Delete menu item appeared');
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
return false;
}
await sleep(150);
realClick(deleteMenuItem);
const confirmBtn = await waitForElement(findDeleteConfirmButton, 2000);
if (!confirmBtn) {
console.warn('SM: No confirmation dialog appeared');
return false;
}
await sleep(150);
realClick(confirmBtn);
// Wait for dialog to close (confirms deletion went through)
await waitForElementGone(() => document.querySelector('[role="dialog"]'), 3000);
await sleep(400);
return true;
}
async function deleteSelected() {
const checked = document.querySelectorAll('input.sm-checkbox:checked');
if (checked.length === 0) return;
const total = checked.length;
const confirmMsg = `Are you sure you want to delete ${total} song(s)? This cannot be undone.`;
if (!confirm(confirmMsg)) return;
$('#sm-delete-selected').prop('disabled', true);
$('#sm-select-all').prop('disabled', true);
let deleted = 0;
let failed = 0;
for (let i = 0; i < total; i++) {
const remaining = document.querySelector('input.sm-checkbox:checked');
if (!remaining) break;
const row = remaining.closest('div.flex.flex-col.gap-2 > div');
if (!row) {
remaining.checked = false;
failed++;
continue;
}
const title = row.querySelector('h3')?.textContent || 'Unknown';
$('#sm-progress').text(`Deleting ${i + 1}/${total}: ${title}...`);
const success = await deleteSong(row);
if (success) {
deleted++;
} else {
failed++;
remaining.checked = false;
}
await sleep(300);
}
$('#sm-progress').text(`Done! Deleted ${deleted}${failed > 0 ? `, ${failed} failed` : ''}`);
setTimeout(() => $('#sm-progress').text(''), 4000);
$('#sm-select-all').prop('disabled', false);
updateCount();
}
// --- Event handlers ---
$('#sm-select-all').on('click', function () {
$('input.sm-checkbox').prop('checked', true).each(function () {
$(this).closest('div.flex.flex-col.gap-2 > div').addClass('sm-row-checked');
});
updateCount();
});
$('#sm-deselect').on('click', function () {
$('input.sm-checkbox').prop('checked', false).each(function () {
$(this).closest('div.flex.flex-col.gap-2 > div').removeClass('sm-row-checked');
});
updateCount();
});
$('#sm-delete-selected').on('click', function () {
deleteSelected();
});
// --- MutationObserver to inject checkboxes on new/changed rows ---
const observer = new MutationObserver(() => {
injectCheckboxes();
});
function startObserving() {
const container = getTrackContainer();
if (container) {
observer.observe(container, { childList: true, subtree: true });
injectCheckboxes();
} else {
setTimeout(startObserving, 1000);
}
}
// Observe the whole body for structural changes (panel open/close changes the ID)
const bodyObserver = new MutationObserver(() => {
const container = getTrackContainer();
if (container) {
injectCheckboxes();
observer.disconnect();
observer.observe(container, { childList: true, subtree: true });
}
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
startObserving();
setInterval(injectCheckboxes, 3000);
console.log('Sonauto Multi-Delete v1.2 loaded!');
})();