From 3375864bf4952c9657995da3ae45ddb4a396800e Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:45:17 +0100 Subject: [PATCH] fix(sections): grey out Add when there is no room for a section Add silently did nothing when the sections left no free space. It is now aria-disabled with a translated reason in its tooltip, kept in sync with sections, the loop, the song and the language. The Guide sentence says so. Closes #775 --- static/css/daw.css | 10 +++ static/index.html | 2 +- static/js/i18n.js | 30 +++++--- static/js/main.js | 5 +- static/js/sections.js | 20 ++++- static/js/state.js | 11 ++- tests/e2e/sections-add-disabled.spec.mjs | 96 ++++++++++++++++++++++++ 7 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 tests/e2e/sections-add-disabled.spec.mjs diff --git a/static/css/daw.css b/static/css/daw.css index 351b2a12..108b0e8d 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -1796,6 +1796,16 @@ input, textarea { font-family: inherit; } border-color: rgba(255,255,255,0.18); } +.sections-add-btn-label[aria-disabled="true"] { + opacity: 0.4; + cursor: not-allowed; +} +.sections-add-btn-label[aria-disabled="true"]:hover { + background: none; + color: var(--muted); + border-color: var(--border); +} + /* Clear-all sections. Neutral until it is armed, then it states plainly that the next click destroys something, because nothing here can be undone. */ .sections-clear-btn[data-armed="1"] { diff --git a/static/index.html b/static/index.html index 03ee2421..97db87cd 100644 --- a/static/index.html +++ b/static/index.html @@ -600,7 +600,7 @@

Lyrics

Clear - diff --git a/static/js/i18n.js b/static/js/i18n.js index cbc8c442..9f38eb3c 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -896,7 +896,7 @@ const en = { "help.newFolder": "Makes a folder, so you can sort your songs.", "help.trashedSong": "A deleted song. Hold it for a second to bring it back.", "help.song": "Click a song to open it in the studio.", - "help.addSection": "Adds a section, like a verse or a chorus, to the sections bar.", + "help.addSection": "Adds a section, like a verse or a chorus, to the sections bar. It is greyed out when there is no free space for one.", "help.volume": "Drag to make {name} louder or quieter.", "help.laneKeyUp": "Moves only {name} up one semitone, the smallest step in music.", "help.laneKeyDown": "Moves only {name} down one semitone, the smallest step in music.", @@ -995,6 +995,7 @@ const en = { "sections.title": "Sections", "sections.add": "Add", "sections.addAria": "Add section", + "sections.addNoRoom": "No free space for a new section", "sections.savingAria": "Saving sections", "mixer.title": "Mixer", @@ -2660,7 +2661,7 @@ const pl = { "help.newFolder": "Tworzy folder, w którym możesz porządkować utwory.", "help.trashedSong": "Usunięty utwór. Przytrzymaj go przez sekundę, aby go przywrócić.", "help.song": "Kliknij utwór, aby otworzyć go w studiu.", - "help.addSection": "Dodaje sekcję, na przykład zwrotkę lub refren, do paska sekcji.", + "help.addSection": "Dodaje sekcję, na przykład zwrotkę lub refren, do paska sekcji. Jest wyszarzony, gdy nie ma na nią wolnego miejsca.", "help.volume": "Przeciągnij, aby {name} grało głośniej lub ciszej.", "help.laneKeyUp": "Podnosi tylko {name} o półton, najmniejszy krok w muzyce.", "help.laneKeyDown": "Obniża tylko {name} o półton, najmniejszy krok w muzyce.", @@ -2759,6 +2760,7 @@ const pl = { "sections.title": "Sekcje", "sections.add": "Dodaj", "sections.addAria": "Dodaj sekcję", + "sections.addNoRoom": "Brak wolnego miejsca na nową sekcję", "sections.savingAria": "Zapisywanie sekcji", "mixer.title": "Mikser", @@ -4414,7 +4416,7 @@ const ja = { "help.newFolder": "曲を整理するためのフォルダーを作ります。", "help.trashedSong": "削除した曲です。1 秒長押しすると元に戻ります。", "help.song": "曲をクリックするとスタジオで開きます。", - "help.addSection": "Aメロやサビなどのセクションをセクションバーに追加します。", + "help.addSection": "Aメロやサビなどのセクションをセクションバーに追加します。 空きがないときはグレーになります。", "help.volume": "ドラッグして{name}の音量を上げ下げします。", "help.laneKeyUp": "{name}だけを半音上げます。半音は音楽で最も小さな音程の単位です。", "help.laneKeyDown": "{name}だけを半音下げます。半音は音楽で最も小さな音程の単位です。", @@ -4513,6 +4515,7 @@ const ja = { "sections.title": "セクション", "sections.add": "追加", "sections.addAria": "セクションを追加", + "sections.addNoRoom": "新しいセクションを置く空きがありません", "sections.savingAria": "セクションを保存中", "mixer.title": "ミキサー", @@ -6143,7 +6146,7 @@ const zhHans = { "help.newFolder": "新建文件夹,用来整理歌曲。", "help.trashedSong": "已删除的歌曲。按住一秒即可恢复。", "help.song": "点击歌曲,在工作室中打开它。", - "help.addSection": "在段落栏中添加一个段落,例如主歌或副歌。", + "help.addSection": "在段落栏中添加一个段落,例如主歌或副歌。 没有空间时按钮会变灰。", "help.volume": "拖动以调大或调小{name}的音量。", "help.laneKeyUp": "只把{name}升高一个半音,半音是音乐中最小的音程。", "help.laneKeyDown": "只把{name}降低一个半音,半音是音乐中最小的音程。", @@ -6242,6 +6245,7 @@ const zhHans = { "sections.title": "段落", "sections.add": "添加", "sections.addAria": "添加段落", + "sections.addNoRoom": "没有空间添加新段落", "sections.savingAria": "正在保存段落", "mixer.title": "混音台", @@ -7872,7 +7876,7 @@ const de = { "help.newFolder": "Legt einen Ordner an, damit du deine Songs sortieren kannst.", "help.trashedSong": "Ein gelöschter Song. Halte ihn eine Sekunde lang gedrückt, um ihn zurückzuholen.", "help.song": "Klick auf einen Song, um ihn im Studio zu öffnen.", - "help.addSection": "Fügt der Abschnittsleiste einen Abschnitt hinzu, etwa eine Strophe oder einen Refrain.", + "help.addSection": "Fügt der Abschnittsleiste einen Abschnitt hinzu, etwa eine Strophe oder einen Refrain. Er ist ausgegraut, wenn kein Platz frei ist.", "help.volume": "Zieh, um {name} lauter oder leiser zu machen.", "help.laneKeyUp": "Hebt nur {name} um einen Halbton an, den kleinsten Schritt in der Musik.", "help.laneKeyDown": "Senkt nur {name} um einen Halbton, den kleinsten Schritt in der Musik.", @@ -7971,6 +7975,7 @@ const de = { "sections.title": "Abschnitte", "sections.add": "Hinzufügen", "sections.addAria": "Abschnitt hinzufügen", + "sections.addNoRoom": "Kein freier Platz für einen neuen Abschnitt", "sections.savingAria": "Abschnitte werden gespeichert", "mixer.title": "Mixer", @@ -9612,7 +9617,7 @@ const pt = { "help.newFolder": "Cria uma pasta para organizar suas músicas.", "help.trashedSong": "Uma música apagada. Segure-a por um segundo para trazê-la de volta.", "help.song": "Clique em uma música para abri-la no estúdio.", - "help.addSection": "Adiciona uma seção, como um verso ou um refrão, à barra de seções.", + "help.addSection": "Adiciona uma seção, como um verso ou um refrão, à barra de seções. Fica cinza quando não há espaço livre.", "help.volume": "Arraste para deixar {name} mais alto ou mais baixo.", "help.laneKeyUp": "Sobe só {name} um semitom, o menor passo na música.", "help.laneKeyDown": "Desce só {name} um semitom, o menor passo na música.", @@ -9711,6 +9716,7 @@ const pt = { "sections.title": "Seções", "sections.add": "Adicionar", "sections.addAria": "Adicionar seção", + "sections.addNoRoom": "Sem espaço livre para uma nova seção", "sections.savingAria": "Salvando seções", "mixer.title": "Mixer", @@ -11354,7 +11360,7 @@ const id = { "help.newFolder": "Membuat folder untuk mengatur lagu Anda.", "help.trashedSong": "Lagu yang dihapus. Tahan selama satu detik untuk mengembalikannya.", "help.song": "Klik lagu untuk membukanya di studio.", - "help.addSection": "Menambahkan bagian, seperti bait atau reff, ke bilah bagian.", + "help.addSection": "Menambahkan bagian, seperti bait atau reff, ke bilah bagian. Tombol ini redup jika tidak ada ruang kosong.", "help.volume": "Geser untuk membuat {name} lebih keras atau lebih pelan.", "help.laneKeyUp": "Menaikkan hanya {name} satu semitone, langkah terkecil dalam musik.", "help.laneKeyDown": "Menurunkan hanya {name} satu semitone, langkah terkecil dalam musik.", @@ -11453,6 +11459,7 @@ const id = { "sections.title": "Bagian", "sections.add": "Tambah", "sections.addAria": "Tambah bagian", + "sections.addNoRoom": "Tidak ada ruang kosong untuk bagian baru", "sections.savingAria": "Menyimpan bagian", "mixer.title": "Mixer", @@ -13083,7 +13090,7 @@ const fr = { "help.newFolder": "Crée un dossier pour ranger vos morceaux.", "help.trashedSong": "Un morceau supprimé. Maintenez-le une seconde pour le récupérer.", "help.song": "Cliquez sur un morceau pour l'ouvrir dans le studio.", - "help.addSection": "Ajoute une section, comme un couplet ou un refrain, à la barre des sections.", + "help.addSection": "Ajoute une section, comme un couplet ou un refrain, à la barre des sections. Il est grisé quand il n'y a pas de place libre.", "help.volume": "Faites glisser pour rendre {name} plus fort ou plus faible.", "help.laneKeyUp": "Monte seulement {name} d'un demi-ton, le plus petit pas en musique.", "help.laneKeyDown": "Descend seulement {name} d'un demi-ton, le plus petit pas en musique.", @@ -13182,6 +13189,7 @@ const fr = { "sections.title": "Sections", "sections.add": "Ajouter", "sections.addAria": "Ajouter une section", + "sections.addNoRoom": "Pas de place libre pour une nouvelle section", "sections.savingAria": "Enregistrement des sections", "mixer.title": "Mixage", @@ -15224,7 +15232,7 @@ const es = { "help.newFolder": "Crea una carpeta para ordenar tus canciones.", "help.trashedSong": "Una canción borrada. Mantenla pulsada un segundo para recuperarla.", "help.song": "Haz clic en una canción para abrirla en el estudio.", - "help.addSection": "Añade una sección, como una estrofa o un estribillo, a la barra de secciones.", + "help.addSection": "Añade una sección, como una estrofa o un estribillo, a la barra de secciones. Aparece en gris cuando no hay espacio libre.", "help.volume": "Arrastra para subir o bajar el volumen de {name}.", "help.laneKeyUp": "Sube solo {name} un semitono, el paso más pequeño en música.", "help.laneKeyDown": "Baja solo {name} un semitono, el paso más pequeño en música.", @@ -15323,6 +15331,7 @@ const es = { "sections.title": "Secciones", "sections.add": "Añadir", "sections.addAria": "Añadir sección", + "sections.addNoRoom": "No hay espacio libre para una nueva sección", "sections.savingAria": "Guardando secciones", "mixer.title": "Mezclador", @@ -16987,7 +16996,7 @@ const ko = { "help.newFolder": "곡을 정리할 폴더를 만들어요.", "help.trashedSong": "삭제한 곡이에요. 1초 동안 누르고 있으면 되살아나요.", "help.song": "곡을 클릭하면 스튜디오에서 열려요.", - "help.addSection": "벌스나 코러스 같은 구간을 섹션 막대에 추가해요.", + "help.addSection": "벌스나 코러스 같은 구간을 섹션 막대에 추가해요. 빈 공간이 없으면 회색으로 바뀌어요.", "help.volume": "끌어서 {name} 소리를 키우거나 줄여요.", "help.laneKeyUp": "{name}만 반음 올려요. 반음은 음악에서 가장 작은 음 간격이에요.", "help.laneKeyDown": "{name}만 반음 내려요. 반음은 음악에서 가장 작은 음 간격이에요.", @@ -17086,6 +17095,7 @@ const ko = { "sections.title": "구간", "sections.add": "추가", "sections.addAria": "구간 추가", + "sections.addNoRoom": "새 구간을 넣을 빈 공간이 없어요", "sections.savingAria": "구간 저장 중", "mixer.title": "믹서", diff --git a/static/js/main.js b/static/js/main.js index c10be1de..0ef601a6 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -4,7 +4,7 @@ import { playBtn, loopBtn, totalDuration, loopEnabled, loopStart, loopEnd, setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady, currentJobId, vocalSplitMode, vocalSplitModeReady, setVocalSplitMode, - setAutoSectionsResetFn, + setAutoSectionsResetFn, onLoopChange, } from "./state.js"; import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js"; import { refreshStemChoiceVisuals } from "./stemChoice.js"; @@ -14,7 +14,7 @@ import { initSearch } from "./search.js"; import { wireTransportButtons } from "./transport.js"; import { wireBeatGridUi } from "./beatgridUi.js"; import { togglePlayPause, updateLoopRegionVisual, toggleMetronome, transport, setPlayheadTime, setLoopRange } from "./transport.js"; -import { setSectionsLoopBridge } from "./sections.js"; +import { setSectionsLoopBridge, refreshSectionsAddState } from "./sections.js"; import { wireStemListControls, wireMixerToolbar, laneDragIcon } from "./mixer.js"; import { initCatalog, collectDiagnostics } from "./catalog.js"; import { initNotifications, notifyFailure, dismissFailuresByJobId } from "./notifications.js"; @@ -485,6 +485,7 @@ setSectionsLoopBridge({ getLoop: () => ({ enabled: loopEnabled, start: loopStart, end: loopEnd }), setLoopRange, }); +onLoopChange(refreshSectionsAddState); wireBeatGridUi(); wireFooterControls(); requestAnimationFrame(drawFooterPlaceholder); diff --git a/static/js/sections.js b/static/js/sections.js index 62e52b03..72730347 100644 --- a/static/js/sections.js +++ b/static/js/sections.js @@ -67,7 +67,11 @@ export function initSections(trackId, sections, duration) { const addBtn = document.getElementById("sectionsAddBtn"); if (addBtn && !addBtn.dataset.sectionsWired) { addBtn.dataset.sectionsWired = "1"; - addBtn.addEventListener("click", () => _addSection()); + addBtn.addEventListener("click", () => { + // aria-disabled, not disabled, so the tooltip still says why. + if (addBtn.getAttribute("aria-disabled") === "true") return; + _addSection(); + }); } _wireClearButton(); @@ -97,6 +101,7 @@ export function destroySections() { if (_container) _container.innerHTML = ""; _container = null; _refreshClearVisibility(); + refreshSectionsAddState(); } // ─── Rendering ──────────────────────────────────────────── @@ -105,6 +110,7 @@ function _render() { // Before the container guard: the button lives in the header, not the // ribbon, so its state must stay correct even when the ribbon is absent. _refreshClearVisibility(); + refreshSectionsAddState(); if (!_container) return; _container.innerHTML = ""; @@ -432,6 +438,18 @@ function _newSectionBounds() { return { start, end }; } +// Add is unavailable exactly when pressing it would do nothing: no place for a +// section (#775) and no armed loop that would at least explain itself with the +// "loop overlaps" notice. Recomputed whenever sections, the song, the loop or +// the language change. +export function refreshSectionsAddState() { + const btn = document.getElementById("sectionsAddBtn"); + if (!btn) return; + const blocked = !_armedLoop() && _newSectionBounds() === null; + btn.setAttribute("aria-disabled", String(blocked)); + btn.title = blocked ? t("sections.addNoRoom") : t("sections.addAria"); +} + function _addSection() { const bounds = _newSectionBounds(); if (!bounds) { diff --git a/static/js/state.js b/static/js/state.js index 046cacc6..8030a3dc 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -198,9 +198,14 @@ export function setCurrentJobId(v) { currentJobId = v; } export function setForegroundJobId(v) { foregroundJobId = v; } export function setTrackIndex(v) { trackIndex = v; } export function setTotalDuration(v) { totalDuration = v; } -export function setLoopEnabled(v) { loopEnabled = v; } -export function setLoopStart(v) { loopStart = v; } -export function setLoopEnd(v) { loopEnd = v; } +// Whoever needs to react to the loop changing (the Sections Add button depends +// on whether a loop is armed) subscribes here rather than polling. +const _loopListeners = []; +export function onLoopChange(fn) { _loopListeners.push(fn); } +function _loopChanged() { for (const fn of _loopListeners) fn(); } +export function setLoopEnabled(v) { loopEnabled = v; _loopChanged(); } +export function setLoopStart(v) { loopStart = v; _loopChanged(); } +export function setLoopEnd(v) { loopEnd = v; _loopChanged(); } export function setAudioContext(v) { audioContext = v; } export function setMasterVolume(v) { masterVolume = v; } export let playbackSpeed = 1.0; diff --git a/tests/e2e/sections-add-disabled.spec.mjs b/tests/e2e/sections-add-disabled.spec.mjs new file mode 100644 index 00000000..c870d03e --- /dev/null +++ b/tests/e2e/sections-add-disabled.spec.mjs @@ -0,0 +1,96 @@ +// The Sections "Add" button is greyed out exactly when pressing it would do +// nothing: a song whose sections leave no room for another one (#775). +// +// Before, a song with detected sections that fill the track showed an Add +// button that looked live and silently did nothing. +import { test, expect } from "@playwright/test"; +import { openStudio, JOB_ID } from "./helpers.mjs"; + +test.afterEach(async ({ page }) => { + await page.request.patch(`/api/jobs/${JOB_ID}/sections`, { data: { sections: [] } }); +}); + +async function seed(page, sections) { + const res = await page.request.patch(`/api/jobs/${JOB_ID}/sections`, { data: { sections } }); + expect(res.ok(), "seeding sections").toBe(true); +} + +// seed.py writes a 6 second fixture. +const FULL = [ + { id: "a", name: "Verse", start: 0, end: 3, color: "#4a7fff" }, + { id: "b", name: "Chorus", start: 3, end: 6, color: "#ff7a4a" }, +]; +const ROOM = [{ id: "a", name: "Verse", start: 0, end: 2, color: "#4a7fff" }]; + +const add = (page) => page.locator("#sectionsAddBtn"); + +test("Add is greyed out, with a reason, when sections fill the song", async ({ page }) => { + await seed(page, FULL); + await openStudio(page); + await expect(page.locator(".section-block")).toHaveCount(2); + + await expect(add(page)).toBeDisabled(); + await expect(add(page)).toHaveAttribute("title", "No free space for a new section"); + + // A press that gets through anyway must still change nothing. + await add(page).evaluate((el) => el.click()); + await expect(page.locator(".section-block")).toHaveCount(2); +}); + +test("Add stays enabled, and works, when there is room", async ({ page }) => { + await seed(page, ROOM); + await openStudio(page); + await expect(page.locator(".section-block")).toHaveCount(1); + + await expect(add(page)).toBeEnabled(); + await expect(add(page)).toHaveAttribute("title", "Add section"); + await add(page).click(); + await expect(page.locator(".section-block")).toHaveCount(2); +}); + +test("Add is enabled with no sections, and again after clearing a full set", async ({ page }) => { + await seed(page, []); + await openStudio(page); + await expect(add(page)).toBeEnabled(); + await add(page).click(); + await expect(page.locator(".section-block")).toHaveCount(1); + + await seed(page, FULL); + await openStudio(page); + await expect(add(page)).toBeDisabled(); + await page.locator("#sectionsClearBtn").click(); + await page.locator("#sectionsClearBtn").click(); + await expect(page.locator(".section-block")).toHaveCount(0); + await expect(add(page)).toBeEnabled(); +}); + +test("Add updates as a section is deleted", async ({ page }) => { + await seed(page, FULL); + await openStudio(page); + await expect(add(page)).toBeDisabled(); + await page.locator('.section-block[data-id="b"] .section-del').evaluate((el) => el.click()); + await expect(page.locator(".section-block")).toHaveCount(1); + await expect(add(page)).toBeEnabled(); +}); + +test("Add follows the loop: a loop in a small gap makes room", async ({ page }) => { + // The only gap, 2.5s to 3.1s, is narrower than a default section, so Add has + // nowhere to put one until a loop there says where. + await seed(page, [ + { id: "a", name: "Verse", start: 0, end: 2.5, color: "#4a7fff" }, + { id: "b", name: "Chorus", start: 3.1, end: 6, color: "#ff7a4a" }, + ]); + await openStudio(page); + await expect(add(page)).toBeDisabled(); + + await page.locator("#t-loop-end").fill("03.100"); + await page.locator("#t-loop-end").blur(); + await page.locator("#t-loop-start").fill("02.500"); + await page.locator("#t-loop-start").blur(); + await expect(page.locator("#t-loop")).toHaveClass(/active/); + await expect(add(page)).toBeEnabled(); + + await page.locator("#t-loop").click(); + await expect(page.locator("#t-loop")).not.toHaveClass(/active/); + await expect(add(page)).toBeDisabled(); +});