diff --git a/app.js b/app.js index 3e93db6..cf154cd 100644 --- a/app.js +++ b/app.js @@ -216,7 +216,7 @@ function homeHtml(){ `; } function toolCard(view,title,desc,type){ - const dots = type === 'transpose' ? '' : type === 'chords' ? '' : ''; + const dots = type === 'transpose' ? '' : type === 'chords' ? '' : ''; return `
0
-
Wynik
+
Wynik
`; } function chordsHtml(){ - return `

Akordy

+ return `

Akordy

- +
-
+
`; } function metronomeHtml(){ return `

Metronom

-
+
Tap tempo
Presety tempa
Metrum
@@ -303,7 +303,7 @@ function initTranspose(){ document.querySelector('#stepUp').onclick = () => { transposeSteps++; update(); }; document.querySelector('#resetTranspose').onclick = () => { transposeSteps = 0; update(); }; document.querySelector('#clearSong').onclick = () => { input.value = ''; update(); input.focus(); }; - document.querySelector('#copyResult').onclick = async () => { await navigator.clipboard?.writeText(input.value.split('\n').map(l => splitWordsAndSpaces(l).map(p => isChordToken(p) ? transposeOneToken(p, transposeSteps) : p).join('')).join('\n')).catch(()=>{}); }; + document.querySelector('#copyResult').onclick = async () => { await navigator.clipboard?.writeText(input.value.split('\n').map(l => splitWordsAndSpaces(l).map(p => isChordToken(p) ? transposeOneToken(p, transposeSteps) : p).join('')).join('\n')); }; document.querySelector('#transposeResultInfo')?.addEventListener('click', () => openAppModal('transpose-result')); update(); } @@ -480,9 +480,9 @@ function updatePendulumSpeed(){ const pendulum = document.querySelector('.metro-pendulum'); if(pendulum) pendulum.style.animationDuration = `${Math.max(180, 60000 / bpm)}ms`; } -function updateMeter(){ const label = meter === 6 ? '6/8' : `${meter}/4`; const el=document.querySelector('#meterInDial'); if(el) el.textContent = label; const modalMeter=document.querySelector('#metroModalMeter'); if(modalMeter) modalMeter.textContent = label; } +function updateMeter(){ const label = meter === 6 ? '6/8' : `${meter}/4`; const el=document.querySelector('#meterInDial'); if(el) el.textContent = label; const modalMeter=document.querySelector('[data-modal-meter]'); if(modalMeter) modalMeter.textContent = label; } function renderBeats(){ - const markup = Array.from({length:meter}, (_,i) => `${i+1}`).join(''); + const markup = Array.from({length:meter}, (_,i) => `${i+1}${'●'}`).join(''); const beats = document.querySelector('#beats'); if(beats) beats.innerHTML = markup; const modalBeats = document.querySelector('#metroModalBeats'); @@ -554,7 +554,7 @@ function openChordModal(chord){ const clean = mainChord(chord); if(!BASE_CHORDS[clean]) return; if(!chordModal || !chordModalContent) return; - chordModalContent.innerHTML = ``; + chordModalContent.innerHTML = ``; chordModal.classList.add('visible'); chordModal.setAttribute('aria-hidden','false'); } function closeChordModal(){ if(!chordModal) return; chordModal.classList.remove('visible'); chordModal.setAttribute('aria-hidden','true'); } @@ -562,7 +562,7 @@ function openAppModal(kind){ if(!appModal || !appModalContent) return; const installMarkup = isPwaInstalled() ? '' : ''; appModalContent.innerHTML = kind === 'options' - ? `

Opcje

EasyTune działa offline po pierwszym uruchomieniu.${isPwaInstalled() ? ' Aplikacja jest już uruchomiona jako zainstalowane PWA.' : ' Aby działała jako pełne PWA, wybierz „Zainstaluj aplikację”, a nie „Dodaj skrót”.'}

${installMarkup}` + ? `

Opcje

EasyTune działa offline po pierwszym uruchomieniu.${isPwaInstalled() ? ' Aplikacja jest już uruchomiona jako zainstalowane PWA.' : ' Aby działała jako pełne PWA, wybierz „Zainstaluj" z menu przeglądarki.'}

${installMarkup}` : kind === 'transpose-result' ? '

Wynik transpozycji

Kliknij dowolny akord w oknie wyniku, aby otworzyć powiększony diagram tego chwytu.

' : `

EasyTune

Transpozycja, akordy i metronom. Wersja PWA offline ${appVersion}.

`; @@ -572,8 +572,23 @@ function openAppModal(kind){ function closeAppModal(){ if(!appModal) return; appModal.classList.remove('visible'); appModal.setAttribute('aria-hidden','true'); } async function loadManifestVersion(){ - try{ const res = await fetch('manifest.webmanifest'); const m = await res.json(); appVersion = m.version || '1.2'; versionEl.textContent = `v${appVersion}`; } catch { versionEl.textContent = `v${appVersion}`; } + try { + const response = await fetch('./manifest.webmanifest'); + if (!response.ok) throw new Error('Failed to fetch manifest'); + const manifest = await response.json(); + if (manifest.version) { + appVersion = manifest.version; + } + } catch (error) { + console.warn('Failed to load version from manifest:', error); + // appVersion stays as default '1.2' + } finally { + if (versionEl) { + versionEl.textContent = `v${appVersion}`; + } + } } + function showApp(){ app.hidden = false; renderView('home'); setTimeout(()=>splash.classList.add('is-hidden'), 850); setTimeout(()=>splash.remove(), 1300); } menuButton?.addEventListener('click', () => { const open = !optionsMenu.hidden; optionsMenu.hidden = open; menuButton.setAttribute('aria-expanded', String(!open)); }); @@ -619,7 +634,7 @@ window.matchMedia?.('(display-mode: fullscreen)')?.addEventListener?.('change', async function promptPwaInstall(){ if (isPwaInstalled()) { syncInstallButtons(); return; } if (!deferredInstallPrompt) { - alert('Przeglądarka nie zgłosiła jeszcze gotowości instalacji PWA. Upewnij się, że strona działa przez HTTPS lub localhost, odśwież ją raz i wybierz w menu przeglądarki „Zainstaluj aplikację” — nie „Dodaj skrót”.'); + alert('Przeglądarka nie zgłosiła jeszcze gotowości instalacji PWA. Upewnij się, że strona działa przez HTTPS lub localhost, odśwież ją raz i wybierz w menu przeglądarki „Zainstaluj aplikację".'); return; } deferredInstallPrompt.prompt(); @@ -629,1530 +644,4 @@ async function promptPwaInstall(){ syncInstallButtons(); } syncInstallButtons(); -loadManifestVersion().finally(() => { showApp(); syncInstallButtons(); }); - -/* ===================================================== - EasyTune v2.5 — CZYSTY METRONOM - Ten blok nadpisuje wyłącznie widok i logikę metronomu. - Pozostałe widoki i modale pozostają bez zmian. - ===================================================== */ -let cleanTapTimes = []; -let cleanMetroTimer = null; -let metroPendulumFrame = null; -let metroPendulumStartedAt = 0; -let metroWasRunningBeforeModal = false; -let metroPausedByModal = false; - -function metronomeHtml(){ - const meterLabel = meter === 6 ? '6/8' : `${meter}/4`; - const accentLabel = accentMode === 'off' ? 'Brak' : accentMode === 'all' ? 'Każdy' : 'Pierwszy'; - return `
-
- -

Metronom

- -
- -
-
- - - -
- -
- -
- - - -
-
- -
- -
- - - - - - - - -
`; -} - -function initMetronome(){ - metroWasRunningBeforeModal = false; - metroPausedByModal = false; - renderMetroControls(); - syncCleanMetronomeUi(); - setBpm(bpm, {restart:false}); - - document.querySelector('#startMetro')?.addEventListener('click', () => metroRunning ? stopMetronome() : startMetronome()); - document.querySelector('#metroResetTop')?.addEventListener('click', resetMetronomeSettings); - document.querySelector('#metroTapInModal')?.addEventListener('click', handleTapTempo); - document.querySelector('#metroBpmDown')?.addEventListener('click', () => setBpm(bpm - 1)); - document.querySelector('#metroBpmUp')?.addEventListener('click', () => setBpm(bpm + 1)); - document.querySelector('#bpmSlider')?.addEventListener('input', e => setBpm(Number(e.target.value))); - document.querySelector('#openTempoModal')?.addEventListener('click', () => openMetroSettings('metroTempoModal')); - document.querySelector('#openMeterModal')?.addEventListener('click', () => openMetroSettings('metroMeterModal')); - document.querySelector('#openAccentModal')?.addEventListener('click', () => openMetroSettings('metroAccentModal')); - document.querySelector('#metroTapInfoClean')?.addEventListener('click', () => { - const modal = document.querySelector('#tapHelpModal'); - pauseMetronomeForModal(); - modal?.classList.add('open'); - modal?.setAttribute('aria-hidden','false'); - }); - document.querySelector('#tapHelpClose')?.addEventListener('click', closeTapHelpModal); - document.querySelector('#tapHelpModal')?.addEventListener('click', e => { - if(e.target === e.currentTarget) closeTapHelpModal(); - }); - - document.querySelectorAll('[data-close-metro-settings]').forEach(btn => btn.addEventListener('click', closeMetroSettings)); - document.querySelectorAll('.metro-settings-modal').forEach(modal => modal.addEventListener('click', e => { - if(e.target === modal) closeMetroSettings(); - })); -} - -function pauseMetronomeForModal(){ - if(metroPausedByModal) return; - metroWasRunningBeforeModal = metroRunning; - if(metroRunning) stopMetronome(); - metroPausedByModal = metroWasRunningBeforeModal; -} -function isAnyMetroModalOpen(){ - const settingsOpen = Array.from(document.querySelectorAll('.metro-settings-modal')).some(m => m.classList.contains('visible')); - const tapHelpOpen = document.querySelector('#tapHelpModal')?.classList.contains('open'); - return settingsOpen || Boolean(tapHelpOpen); -} -function resumeMetronomeAfterModal(){ - if(isAnyMetroModalOpen()) return; - const shouldResume = metroPausedByModal && metroWasRunningBeforeModal; - metroPausedByModal = false; - metroWasRunningBeforeModal = false; - if(shouldResume && !metroRunning) startMetronome(); -} - -function openMetroSettings(id){ - pauseMetronomeForModal(); - document.querySelectorAll('.metro-settings-modal').forEach(m => { - m.classList.remove('visible'); - m.setAttribute('aria-hidden','true'); - }); - const modal = document.querySelector('#' + id); - if(!modal) return; - modal.classList.add('visible'); - modal.setAttribute('aria-hidden','false'); -} -function closeMetroSettings(){ - document.querySelectorAll('.metro-settings-modal').forEach(m => { - m.classList.remove('visible'); - m.setAttribute('aria-hidden','true'); - }); - resumeMetronomeAfterModal(); -} - -function closeTapHelpModal(){ - const modal = document.querySelector('#tapHelpModal'); - if(!modal) return; - modal.classList.remove('open'); - modal.setAttribute('aria-hidden','true'); - resumeMetronomeAfterModal(); -} - -function renderMetroControls(){ - const presets = [60, 80, 100, 120, 140, 160]; - const presetGrid = document.querySelector('#presetGrid'); - if(presetGrid){ - presetGrid.innerHTML = presets.map(v => ``).join(''); - presetGrid.onclick = e => { - const btn = e.target.closest('[data-bpm]'); - if(!btn) return; - setBpm(Number(btn.dataset.bpm)); - renderMetroControls(); - }; - } - - const meterButtons = document.querySelector('#meterButtons'); - if(meterButtons){ - meterButtons.innerHTML = [2,3,4,5,6].map(v => ``).join(''); - meterButtons.onclick = e => { - const btn = e.target.closest('[data-meter]'); - if(!btn) return; - meter = Number(btn.dataset.meter); - beatIndex = 0; - saveMetroSettings(); - renderMetroControls(); - renderBeats(); - syncCleanMetronomeUi(); - }; - } - - const accentButtons = document.querySelector('#accentButtons'); - if(accentButtons){ - accentButtons.innerHTML = [ - ['first','Pierwszy'], - ['all','Każdy'], - ['off','Brak'] - ].map(([v,l]) => ``).join(''); - accentButtons.onclick = e => { - const btn = e.target.closest('[data-accent]'); - if(!btn) return; - accentMode = btn.dataset.accent; - beatIndex = 0; - saveMetroSettings(); - renderMetroControls(); - renderBeats(); - syncCleanMetronomeUi(); - }; - } - - renderBeats(); - syncCleanMetronomeUi(); -} - - -function stopPendulumMotion(reset=true){ - if(metroPendulumFrame){ - cancelAnimationFrame(metroPendulumFrame); - metroPendulumFrame = null; - } - if(reset){ - const pendulum = document.querySelector('.metro-clean-pendulum'); - if(pendulum) pendulum.style.setProperty('transform', 'translateX(-50%) rotate(0deg)', 'important'); - } -} - -function startPendulumMotion(){ - stopPendulumMotion(false); - const pendulum = document.querySelector('.metro-clean-pendulum'); - if(!pendulum) return; - const beatMs = Math.max(180, 60000 / bpm); - const maxAngle = 38; - metroPendulumStartedAt = performance.now(); - - const draw = (now) => { - if(!metroRunning){ - metroPendulumFrame = null; - return; - } - - // Jeden mechanizm ruchu: pozycja zależy wyłącznie od czasu i BPM. - // Uderzenia wypadają na skrajach wahadła: lewo, prawo, lewo... - const elapsed = now - metroPendulumStartedAt; - const phase = (elapsed % (beatMs * 2)) / beatMs; - const angle = phase <= 1 - ? -maxAngle + (phase * 2 * maxAngle) - : maxAngle - ((phase - 1) * 2 * maxAngle); - - pendulum.style.setProperty('transform', `translateX(-50%) rotate(${angle.toFixed(2)}deg)`, 'important'); - metroPendulumFrame = requestAnimationFrame(draw); - }; - - pendulum.style.setProperty('transform', `translateX(-50%) rotate(${-maxAngle}deg)`, 'important'); - metroPendulumFrame = requestAnimationFrame(draw); -} - -function syncCleanMetronomeUi(){ - const meterLabel = meter === 6 ? '6/8' : `${meter}/4`; - const accentLabel = accentMode === 'off' ? 'Brak' : accentMode === 'all' ? 'Każdy' : 'Pierwszy'; - - const bpmText = document.querySelector('#bpmValue'); - const modalBpm = document.querySelector('#metroModalBpm'); - const slider = document.querySelector('#bpmSlider'); - const tempoMeta = document.querySelector('#metroTempoMeta'); - const meterMeta = document.querySelector('#meterInDial'); - const accentMeta = document.querySelector('#metroAccentMeta'); - const visual = document.querySelector('#metroCleanVisual'); - const start = document.querySelector('#startMetro'); - - if(bpmText) bpmText.textContent = bpm; - if(modalBpm) modalBpm.textContent = bpm; - if(slider) slider.value = bpm; - if(tempoMeta) tempoMeta.textContent = `${bpm} BPM`; - if(meterMeta) meterMeta.textContent = meterLabel; - if(accentMeta) accentMeta.textContent = accentLabel; - if(start) start.textContent = metroRunning ? 'Stop' : 'Start'; - - if(visual){ - // Jedyny ruch metronomu: wahadło synchronizowane z interwałem BPM. - visual.style.setProperty('--metro-duration', `${60000 / bpm}ms`); - visual.classList.toggle('running', metroRunning); - } -} - -function setBpm(v, opts={restart:true}){ - bpm = Math.max(40, Math.min(240, Math.round(v))); - saveMetroSettings(); - syncCleanMetronomeUi(); - document.querySelectorAll('.preset-bpm').forEach(b => b.classList.toggle('active', Number(b.dataset.bpm) === bpm)); - if(opts.restart && metroRunning){ - stopMetronome(); - startMetronome(); - } -} - -function saveMetroSettings(){ - localStorage.setItem('easytune-metro-bpm', String(bpm)); - localStorage.setItem('easytune-metro-meter', String(meter)); - localStorage.setItem('easytune-metro-accent', accentMode); -} - -function resetMetronomeSettings(){ - stopMetronome(); - bpm = 100; - meter = 4; - accentMode = 'first'; - cleanTapTimes = []; - beatIndex = 0; - saveMetroSettings(); - renderMetroControls(); - syncCleanMetronomeUi(); -} - -function handleTapTempo(){ - const now = Date.now(); - cleanTapTimes = cleanTapTimes.filter(t => now - t < 2200); - cleanTapTimes.push(now); - if(cleanTapTimes.length < 2) return; - const intervals = cleanTapTimes.slice(1).map((t,i)=> t - cleanTapTimes[i]); - const avg = intervals.reduce((a,b)=>a+b,0) / intervals.length; - setBpm(Math.round(60000 / avg)); -} - -function renderBeats(){ - const beats = document.querySelector('#beats'); - if(!beats) return; - beats.innerHTML = Array.from({length:meter}, (_,i)=>{ - const active = i === beatIndex; - const accent = accentMode === 'all' || (accentMode === 'first' && i === 0); - return `${i+1}`; - }).join(''); -} - -function ensureAudio(){ - if(!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - if(audioCtx.state === 'suspended') audioCtx.resume(); -} -function clickSound(accented){ - ensureAudio(); - const osc = audioCtx.createOscillator(); - const gain = audioCtx.createGain(); - osc.type = 'square'; - osc.frequency.value = accented ? 1300 : 820; - gain.gain.setValueAtTime(0.0001, audioCtx.currentTime); - gain.gain.exponentialRampToValueAtTime(accented ? 0.22 : 0.13, audioCtx.currentTime + 0.006); - gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.07); - osc.connect(gain).connect(audioCtx.destination); - osc.start(); - osc.stop(audioCtx.currentTime + 0.08); -} -function tick(){ - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - clickSound(accented); - renderBeats(); - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - if(accented){ - void visual.offsetWidth; - visual.classList.add('accent-hit'); - window.setTimeout(()=>visual.classList.remove('accent-hit'), 220); - } - } - beatIndex = (beatIndex + 1) % meter; -} -function startMetronome(){ - ensureAudio(); - metroRunning = true; - beatIndex = 0; - clearInterval(cleanMetroTimer); - clearInterval(metroTimer); - cleanMetroTimer = null; - metroTimer = null; - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - visual.style.setProperty('--metro-duration', `${60000 / bpm}ms`); - } - syncCleanMetronomeUi(); - startPendulumMotion(); - tick(); - cleanMetroTimer = setInterval(tick, 60000 / bpm); - metroTimer = cleanMetroTimer; -} -function stopMetronome(){ - metroRunning = false; - clearInterval(cleanMetroTimer); - clearInterval(metroTimer); - cleanMetroTimer = null; - metroTimer = null; - stopPendulumMotion(true); - syncCleanMetronomeUi(); - renderBeats(); -} - - -// v2.5.6 help modal -document.addEventListener('click', function(e){ - if(e.target && e.target.id==='tapHelpModal'){ - e.target.classList.remove('open'); - } -}); - -/* EasyTune v40 — metronome unified clock: one trigger for sound, dots, accent light and pendulum */ -let metroUnifiedBeatCount = 0; -let metroUnifiedStartAt = 0; - -function stopPendulumMotion(reset=true){ - if(metroPendulumFrame){ - cancelAnimationFrame(metroPendulumFrame); - metroPendulumFrame = null; - } - const pendulum = document.querySelector('.metro-clean-pendulum'); - if(reset && pendulum){ - pendulum.style.setProperty('transform', 'translateX(-50%) rotate(0deg)', 'important'); - } -} - -function startPendulumMotion(startAt = performance.now()){ - stopPendulumMotion(false); - const pendulum = document.querySelector('.metro-clean-pendulum'); - if(!pendulum) return; - const beatMs = Math.max(180, 60000 / bpm); - const maxAngle = 38; - metroPendulumStartedAt = startAt; - - const draw = (now) => { - if(!metroRunning){ - metroPendulumFrame = null; - return; - } - const elapsed = Math.max(0, now - metroPendulumStartedAt); - const phase = (elapsed % (beatMs * 2)) / beatMs; - const angle = phase <= 1 - ? -maxAngle + (phase * 2 * maxAngle) - : maxAngle - ((phase - 1) * 2 * maxAngle); - pendulum.style.setProperty('transform', `translateX(-50%) rotate(${angle.toFixed(2)}deg)`, 'important'); - metroPendulumFrame = requestAnimationFrame(draw); - }; - - pendulum.style.setProperty('transform', `translateX(-50%) rotate(${-maxAngle}deg)`, 'important'); - metroPendulumFrame = requestAnimationFrame(draw); -} - -function metronomeBeat(){ - if(!metroRunning) return; - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - clickSound(accented); - renderBeats(); - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - if(accented){ - void visual.offsetWidth; - visual.classList.add('accent-hit'); - window.setTimeout(()=>visual.classList.remove('accent-hit'), 180); - } - } - beatIndex = (beatIndex + 1) % meter; -} - -function tick(){ - metronomeBeat(); -} - -function scheduleNextMetronomeBeat(){ - if(!metroRunning) return; - const beatMs = Math.max(180, 60000 / bpm); - metroUnifiedBeatCount += 1; - const dueAt = metroUnifiedStartAt + metroUnifiedBeatCount * beatMs; - const delay = Math.max(0, dueAt - performance.now()); - cleanMetroTimer = window.setTimeout(() => { - metronomeBeat(); - scheduleNextMetronomeBeat(); - }, delay); - metroTimer = cleanMetroTimer; -} - -function startMetronome(){ - ensureAudio(); - stopMetronome(); - metroRunning = true; - beatIndex = 0; - metroUnifiedBeatCount = 0; - metroUnifiedStartAt = performance.now(); - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - visual.style.setProperty('--metro-duration', `${60000 / bpm}ms`); - } - syncCleanMetronomeUi(); - startPendulumMotion(metroUnifiedStartAt); - metronomeBeat(); - scheduleNextMetronomeBeat(); -} - -function stopMetronome(){ - metroRunning = false; - if(cleanMetroTimer) window.clearTimeout(cleanMetroTimer); - if(metroTimer) window.clearTimeout(metroTimer); - window.clearInterval(cleanMetroTimer); - window.clearInterval(metroTimer); - cleanMetroTimer = null; - metroTimer = null; - stopPendulumMotion(true); - syncCleanMetronomeUi(); - renderBeats(); -} - -/* EasyTune v41 — metronome: extrema-driven single trigger - Sound, accent light and beat dots fire ONLY when the needle is at max left/right. - The circle never pulses. The needle transition between extrema is visual only. */ -let metroExtremeSide = -1; -let metroMoveTimer = null; -let metroMoveSerial = 0; - -function metroBeatMs(){ - return Math.max(180, 60000 / bpm); -} - -function setMetroNeedleSide(side, immediate=false){ - const pendulum = document.querySelector('.metro-clean-pendulum'); - if(!pendulum) return; - const angle = side < 0 ? -38 : 38; - if(immediate){ - pendulum.style.setProperty('transition', 'none', 'important'); - pendulum.style.setProperty('transform', `translateX(-50%) rotate(${angle}deg)`, 'important'); - pendulum.getBoundingClientRect(); - } else { - pendulum.style.setProperty('transition', `transform ${metroBeatMs()}ms cubic-bezier(.37,0,.63,1)`, 'important'); - pendulum.style.setProperty('transform', `translateX(-50%) rotate(${angle}deg)`, 'important'); - } -} - -function clearMetroClock(){ - metroMoveSerial += 1; - if(metroMoveTimer) window.clearTimeout(metroMoveTimer); - if(cleanMetroTimer) window.clearTimeout(cleanMetroTimer); - if(metroTimer) window.clearTimeout(metroTimer); - window.clearInterval(cleanMetroTimer); - window.clearInterval(metroTimer); - metroMoveTimer = null; - cleanMetroTimer = null; - metroTimer = null; - if(metroPendulumFrame){ - cancelAnimationFrame(metroPendulumFrame); - metroPendulumFrame = null; - } -} - -function fireMetroExtremeBeat(){ - if(!metroRunning) return; - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - clickSound(accented); - renderBeats(); - - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - if(accented){ - void visual.offsetWidth; - visual.classList.add('accent-hit'); - window.setTimeout(() => visual.classList.remove('accent-hit'), 180); - } - } - beatIndex = (beatIndex + 1) % meter; -} - -function scheduleMetroExtremeLoop(serial){ - if(!metroRunning || serial !== metroMoveSerial) return; - const beatMs = metroBeatMs(); - const nextSide = -metroExtremeSide; - - // After the beat fires at the current extreme, move toward the next extreme. - requestAnimationFrame(() => { - if(!metroRunning || serial !== metroMoveSerial) return; - setMetroNeedleSide(nextSide, false); - }); - - metroMoveTimer = window.setTimeout(() => { - if(!metroRunning || serial !== metroMoveSerial) return; - metroExtremeSide = nextSide; - // Confirm exact endpoint before firing the next beat. - setMetroNeedleSide(metroExtremeSide, true); - fireMetroExtremeBeat(); - scheduleMetroExtremeLoop(serial); - }, beatMs); - cleanMetroTimer = metroMoveTimer; - metroTimer = metroMoveTimer; -} - -function tick(){ - fireMetroExtremeBeat(); -} - -function stopPendulumMotion(reset=true){ - clearMetroClock(); - if(reset){ - const pendulum = document.querySelector('.metro-clean-pendulum'); - if(pendulum){ - pendulum.style.setProperty('transition','none','important'); - pendulum.style.setProperty('transform','translateX(-50%) rotate(0deg)','important'); - } - } -} - -function startPendulumMotion(){ - // Kept for compatibility; real motion is driven by startMetronome(). - setMetroNeedleSide(metroExtremeSide, true); -} - -function startMetronome(){ - ensureAudio(); - stopMetronome(); - metroRunning = true; - beatIndex = 0; - metroExtremeSide = -1; - metroMoveSerial += 1; - const serial = metroMoveSerial; - - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - visual.style.setProperty('--metro-duration', `${metroBeatMs()}ms`); - } - - setMetroNeedleSide(metroExtremeSide, true); - syncCleanMetronomeUi(); - fireMetroExtremeBeat(); - scheduleMetroExtremeLoop(serial); -} - -function stopMetronome(){ - metroRunning = false; - clearMetroClock(); - const pendulum = document.querySelector('.metro-clean-pendulum'); - if(pendulum){ - pendulum.style.setProperty('transition','none','important'); - pendulum.style.setProperty('transform','translateX(-50%) rotate(0deg)','important'); - } - syncCleanMetronomeUi(); - renderBeats(); -} - -let metroEndpointTimer = null; - -/* EasyTune v45 — metronome: single RAF clock, sound/light at needle extrema. - No CSS swing, no circle pulse, no frame flash. The same animation frame sets - the needle endpoint and fires the sound/accent light. */ -let metroRafId = null; -let metroRafStart = 0; -let metroLastBeatOrdinal = -1; -let metroRunSerial = 0; - -function metroBeatMs(){ - return Math.max(180, 60000 / bpm); -} - -function metroMaxAngle(){ return 38; } - -function setMetroNeedleAngle(angle){ - const pendulum = document.querySelector('.metro-clean-pendulum'); - if(!pendulum) return; - pendulum.style.setProperty('animation', 'none', 'important'); - pendulum.style.setProperty('transition', 'none', 'important'); - pendulum.style.setProperty('transform-origin', '50% 100%', 'important'); - pendulum.style.setProperty('transform', `translateX(-50%) rotate(${angle}deg)`, 'important'); -} - -function setMetroNeedleExtreme(side){ - setMetroNeedleAngle(side < 0 ? -metroMaxAngle() : metroMaxAngle()); -} - -function clearMetroEndpointClock(){ - metroRunSerial += 1; - if(metroRafId){ - cancelAnimationFrame(metroRafId); - metroRafId = null; - } - if(cleanMetroTimer) window.clearTimeout(cleanMetroTimer); - if(metroTimer) window.clearTimeout(metroTimer); - window.clearInterval(cleanMetroTimer); - window.clearInterval(metroTimer); - cleanMetroTimer = null; - metroTimer = null; - if(typeof metroEndpointTimer !== 'undefined' && metroEndpointTimer) window.clearTimeout(metroEndpointTimer); - metroEndpointTimer = null; - if(metroPendulumFrame){ - cancelAnimationFrame(metroPendulumFrame); - metroPendulumFrame = null; - } -} - -function fireMetroBeatFromNeedle(beatOrdinal){ - if(!metroRunning) return; - const side = beatOrdinal % 2 === 0 ? -1 : 1; - setMetroNeedleExtreme(side); - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - clickSound(accented); - renderBeats(); - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - if(accented){ - void visual.offsetWidth; - visual.classList.add('accent-hit'); - window.setTimeout(() => visual.classList.remove('accent-hit'), 150); - } - } - beatIndex = (beatIndex + 1) % meter; -} - -function metroAnimationFrame(serial, now){ - if(!metroRunning || serial !== metroRunSerial) return; - const beatMs = metroBeatMs(); - const elapsed = Math.max(0, now - metroRafStart); - const beatOrdinal = Math.floor(elapsed / beatMs); - - if(beatOrdinal !== metroLastBeatOrdinal){ - metroLastBeatOrdinal = beatOrdinal; - fireMetroBeatFromNeedle(beatOrdinal); - } else { - // Left extreme at beat 0, right extreme at beat 1, etc. - const angle = -metroMaxAngle() * Math.cos(Math.PI * elapsed / beatMs); - setMetroNeedleAngle(angle); - } - - metroRafId = requestAnimationFrame(t => metroAnimationFrame(serial, t)); -} - -function startMetronome(){ - ensureAudio(); - clearMetroEndpointClock(); - metroRunning = true; - beatIndex = 0; - metroLastBeatOrdinal = -1; - const serial = metroRunSerial; - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - visual.classList.add('running'); - visual.style.setProperty('--metro-duration', `${metroBeatMs()}ms`); - } - - // Start visibly at the left endpoint, then the RAF clock becomes the single source of truth. - setMetroNeedleExtreme(-1); - syncCleanMetronomeUi(); - requestAnimationFrame(() => { - requestAnimationFrame(t => { - if(!metroRunning || serial !== metroRunSerial) return; - metroRafStart = t; - metroRafId = requestAnimationFrame(tt => metroAnimationFrame(serial, tt)); - }); - }); -} - -function stopMetronome(){ - metroRunning = false; - clearMetroEndpointClock(); - setMetroNeedleAngle(0); - const visual = document.querySelector('#metroCleanVisual'); - if(visual) visual.classList.remove('running','beat-hit','accent-hit'); - syncCleanMetronomeUi(); - renderBeats(); -} - -function tick(){ - fireMetroBeatFromNeedle(Math.max(0, metroLastBeatOrdinal + 1)); -} - -function startPendulumMotion(){ - if(!metroRunning) setMetroNeedleExtreme(-1); -} - -function stopPendulumMotion(reset=true){ - clearMetroEndpointClock(); - if(reset) setMetroNeedleAngle(0); -} - -/* EasyTune v47 — FINAL metronome phase fix. - Start needle at LEFT extreme. Do NOT click at start. - First click/dot/light fires only when the needle reaches RIGHT extreme, - then on every next extreme. Circle/frame pulse stays disabled by CSS. */ -function metroAnimationFrame(serial, now){ - if(!metroRunning || serial !== metroRunSerial) return; - const beatMs = metroBeatMs(); - const elapsed = Math.max(0, now - metroRafStart); - - // Position is the source of truth: t=0 left, t=beatMs right, t=2*beatMs left. - const angle = -metroMaxAngle() * Math.cos(Math.PI * elapsed / beatMs); - setMetroNeedleAngle(angle); - - const beatOrdinal = Math.floor(elapsed / beatMs); - if(beatOrdinal >= 1 && beatOrdinal !== metroLastBeatOrdinal){ - metroLastBeatOrdinal = beatOrdinal; - fireMetroBeatFromNeedle(beatOrdinal); - } - - metroRafId = requestAnimationFrame(t => metroAnimationFrame(serial, t)); -} - -function startMetronome(){ - ensureAudio(); - clearMetroEndpointClock(); - metroRunning = true; - beatIndex = 0; - metroLastBeatOrdinal = 0; // start is visual only; first audible beat is at RIGHT extreme - const serial = metroRunSerial; - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - visual.classList.add('running'); - visual.style.setProperty('--metro-duration', `${metroBeatMs()}ms`); - } - - setMetroNeedleExtreme(-1); - renderBeats(); - syncCleanMetronomeUi(); - - requestAnimationFrame(t => { - if(!metroRunning || serial !== metroRunSerial) return; - metroRafStart = t; - metroRafId = requestAnimationFrame(tt => metroAnimationFrame(serial, tt)); - }); -} - -function stopMetronome(){ - metroRunning = false; - clearMetroEndpointClock(); - setMetroNeedleAngle(0); - const visual = document.querySelector('#metroCleanVisual'); - if(visual) visual.classList.remove('running','beat-hit','accent-hit'); - syncCleanMetronomeUi(); - renderBeats(); -} - -/* EasyTune v48 — metronome endpoint clock final override. - Idle/start needle is LEFT. First sound/dot fires only after the needle reaches RIGHT. - A single timeout-driven endpoint loop controls needle, sound, dots and accent light. */ -let metroEndpointLoopTimer = null; -let metroEndpointSerialFinal = 0; -let metroEndpointSideFinal = -1; - -function metroBeatMs(){ - return Math.max(180, 60000 / bpm); -} -function metroMaxAngle(){ return 38; } -function metroAngleForSide(side){ return side < 0 ? -metroMaxAngle() : metroMaxAngle(); } - -function setMetroNeedleEndpoint(side, animate=false){ - const pendulum = document.querySelector('.metro-clean-pendulum'); - if(!pendulum) return; - pendulum.style.setProperty('animation', 'none', 'important'); - pendulum.style.setProperty('transform-origin', '50% 100%', 'important'); - pendulum.style.setProperty('transition', animate ? `transform ${metroBeatMs()}ms cubic-bezier(.37,0,.63,1)` : 'none', 'important'); - pendulum.style.setProperty('transform', `translateX(-50%) rotate(${metroAngleForSide(side)}deg)`, 'important'); -} - -function clearMetroEndpointLoopFinal(){ - metroEndpointSerialFinal += 1; - if(metroEndpointLoopTimer) window.clearTimeout(metroEndpointLoopTimer); - metroEndpointLoopTimer = null; - if(cleanMetroTimer) window.clearTimeout(cleanMetroTimer); - if(metroTimer) window.clearTimeout(metroTimer); - if(cleanMetroTimer) window.clearInterval(cleanMetroTimer); - if(metroTimer) window.clearInterval(metroTimer); - cleanMetroTimer = null; - metroTimer = null; - if(metroRafId){ cancelAnimationFrame(metroRafId); metroRafId = null; } - if(metroPendulumFrame){ cancelAnimationFrame(metroPendulumFrame); metroPendulumFrame = null; } -} - -function fireMetroEndpointBeatFinal(){ - if(!metroRunning) return; - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - clickSound(accented); - renderBeats(); - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - if(accented){ - void visual.offsetWidth; - visual.classList.add('accent-hit'); - window.setTimeout(() => visual.classList.remove('accent-hit'), 150); - } - } - beatIndex = (beatIndex + 1) % meter; -} - -function runMetroEndpointLoopFinal(serial){ - if(!metroRunning || serial !== metroEndpointSerialFinal) return; - const beatMs = metroBeatMs(); - const nextSide = -metroEndpointSideFinal; - - // Move first. The beat happens only after the visual needle has arrived. - requestAnimationFrame(() => { - if(!metroRunning || serial !== metroEndpointSerialFinal) return; - setMetroNeedleEndpoint(nextSide, true); - }); - - metroEndpointLoopTimer = window.setTimeout(() => { - if(!metroRunning || serial !== metroEndpointSerialFinal) return; - metroEndpointSideFinal = nextSide; - setMetroNeedleEndpoint(metroEndpointSideFinal, false); // snap to exact edge before click - fireMetroEndpointBeatFinal(); - runMetroEndpointLoopFinal(serial); - }, beatMs); - cleanMetroTimer = metroEndpointLoopTimer; - metroTimer = metroEndpointLoopTimer; -} - -function startMetronome(){ - ensureAudio(); - clearMetroEndpointLoopFinal(); - metroRunning = true; - beatIndex = 0; - metroEndpointSideFinal = -1; - const serial = metroEndpointSerialFinal; - const visual = document.querySelector('#metroCleanVisual'); - if(visual){ - visual.classList.remove('beat-hit','accent-hit'); - visual.classList.add('running'); - visual.style.setProperty('--metro-duration', `${metroBeatMs()}ms`); - } - setMetroNeedleEndpoint(-1, false); // visible LEFT start; no sound here - renderBeats(); - syncCleanMetronomeUi(); - runMetroEndpointLoopFinal(serial); -} - -function stopMetronome(){ - metroRunning = false; - clearMetroEndpointLoopFinal(); - metroEndpointSideFinal = -1; - setMetroNeedleEndpoint(-1, false); - const visual = document.querySelector('#metroCleanVisual'); - if(visual) visual.classList.remove('running','beat-hit','accent-hit'); - syncCleanMetronomeUi(); - beatIndex = 0; - renderBeats(); -} - -function tick(){ - fireMetroEndpointBeatFinal(); -} -function startPendulumMotion(){ - if(!metroRunning) setMetroNeedleEndpoint(-1, false); -} -function stopPendulumMotion(reset=true){ - clearMetroEndpointLoopFinal(); - if(reset) setMetroNeedleEndpoint(-1, false); -} - -/* EasyTune v50 — metronom napisany od nowa: start = maksymalny lewy wychył. - Jeden zegar endpointów. Brak dźwięku na starcie. Pierwsze piknięcie dopiero - po dojściu wskazówki do prawego skraju. */ -(function(){ - const LEFT = -1; - const RIGHT = 1; - const MAX_ANGLE = 38; - let endpointTimer = null; - let endpointSerial = 0; - let endpointSide = LEFT; - - function msPerBeat(){ return Math.max(180, 60000 / bpm); } - function angleFor(side){ return side === LEFT ? -MAX_ANGLE : MAX_ANGLE; } - - function needle(){ return document.querySelector('.metro-clean-pendulum'); } - function visual(){ return document.querySelector('#metroCleanVisual'); } - - function forceNeedle(side){ - const p = needle(); - if(!p) return; - p.style.setProperty('animation', 'none', 'important'); - p.style.setProperty('transition', 'none', 'important'); - p.style.setProperty('transform-origin', '50% 100%', 'important'); - p.style.setProperty('transform', `translateX(-50%) rotate(${angleFor(side)}deg)`, 'important'); - } - - function moveNeedle(side){ - const p = needle(); - if(!p) return; - p.style.setProperty('animation', 'none', 'important'); - p.style.setProperty('transition', `transform ${msPerBeat()}ms cubic-bezier(.37,0,.63,1)`, 'important'); - p.style.setProperty('transform-origin', '50% 100%', 'important'); - p.style.setProperty('transform', `translateX(-50%) rotate(${angleFor(side)}deg)`, 'important'); - } - - function clearMetronomeClock(){ - endpointSerial += 1; - if(endpointTimer) window.clearTimeout(endpointTimer); - endpointTimer = null; - if(cleanMetroTimer) { window.clearTimeout(cleanMetroTimer); window.clearInterval(cleanMetroTimer); } - if(metroTimer) { window.clearTimeout(metroTimer); window.clearInterval(metroTimer); } - cleanMetroTimer = null; - metroTimer = null; - if(metroPendulumFrame){ cancelAnimationFrame(metroPendulumFrame); metroPendulumFrame = null; } - if(typeof metroRafId !== 'undefined' && metroRafId){ cancelAnimationFrame(metroRafId); metroRafId = null; } - } - - function fireBeatAtCurrentEdge(){ - if(!metroRunning) return; - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - clickSound(accented); - renderBeats(); - const v = visual(); - if(v){ - v.classList.remove('beat-hit','accent-hit'); - if(accented){ - void v.offsetWidth; - v.classList.add('accent-hit'); - window.setTimeout(() => v.classList.remove('accent-hit'), 150); - } - } - beatIndex = (beatIndex + 1) % meter; - } - - function scheduleMove(serial){ - if(!metroRunning || serial !== endpointSerial) return; - const target = endpointSide === LEFT ? RIGHT : LEFT; - moveNeedle(target); - - endpointTimer = window.setTimeout(() => { - if(!metroRunning || serial !== endpointSerial) return; - endpointSide = target; - forceNeedle(endpointSide); // dokładny skraj PRZED dźwiękiem - fireBeatAtCurrentEdge(); // dźwięk/kropka/światło wyłącznie na skraju - scheduleMove(serial); - }, msPerBeat()); - - cleanMetroTimer = endpointTimer; - metroTimer = endpointTimer; - } - - window.setMetronomeNeedleLeft = function(){ - endpointSide = LEFT; - forceNeedle(LEFT); - }; - - // Nadpisanie poprzednich implementacji. - startMetronome = function(){ - ensureAudio(); - clearMetronomeClock(); - metroRunning = true; - beatIndex = 0; - endpointSide = LEFT; - const serial = endpointSerial; - const v = visual(); - if(v){ - v.classList.remove('beat-hit','accent-hit'); - v.classList.add('running'); - v.style.setProperty('--metro-duration', `${msPerBeat()}ms`); - } - forceNeedle(LEFT); // START: maksymalny lewy wychył - renderBeats(); - syncCleanMetronomeUi(); - requestAnimationFrame(() => scheduleMove(serial)); // pierwsze piknięcie po dojściu do prawego skraju - }; - - stopMetronome = function(){ - metroRunning = false; - clearMetronomeClock(); - endpointSide = LEFT; - forceNeedle(LEFT); // STOP/idle: nadal maksymalny lewy wychył - const v = visual(); - if(v) v.classList.remove('running','beat-hit','accent-hit'); - beatIndex = 0; - syncCleanMetronomeUi(); - renderBeats(); - }; - - startPendulumMotion = function(){ forceNeedle(LEFT); }; - stopPendulumMotion = function(){ clearMetronomeClock(); forceNeedle(LEFT); }; - tick = function(){ fireBeatAtCurrentEdge(); }; - - const previousInitMetronome = initMetronome; - initMetronome = function(){ - previousInitMetronome(); - if(!metroRunning) requestAnimationFrame(() => forceNeedle(LEFT)); - }; -})(); - -/* EasyTune v51 — METRONOME FINAL: RAF is the only clock. - Start/idle needle is max LEFT. No sound at start. First beat fires at max RIGHT. - Sound, dots and accent light are triggered in the same animation frame that draws the endpoint. */ -(function(){ - const LEFT = -1; - const RIGHT = 1; - const MAX_ANGLE = 38; - let rafId = null; - let runSerial = 0; - let runStart = 0; - let lastEndpoint = 0; // 0 means visual start only; first audible endpoint is 1 (RIGHT) - - function beatMs(){ return Math.max(180, 60000 / bpm); } - function needle(){ return document.querySelector('.metro-clean-pendulum'); } - function visual(){ return document.querySelector('#metroCleanVisual'); } - function angleForEndpoint(endpoint){ return endpoint % 2 === 0 ? -MAX_ANGLE : MAX_ANGLE; } - - function drawNeedle(angle){ - const p = needle(); - if(!p) return; - p.style.setProperty('animation', 'none', 'important'); - p.style.setProperty('transition', 'none', 'important'); - p.style.setProperty('transform-origin', '50% 100%', 'important'); - p.style.setProperty('transform', `translateX(-50%) rotate(${angle}deg)`, 'important'); - } - - function drawLeftStart(){ drawNeedle(-MAX_ANGLE); } - - function clearRafClock(){ - runSerial += 1; - if(rafId){ cancelAnimationFrame(rafId); rafId = null; } - if(cleanMetroTimer){ clearTimeout(cleanMetroTimer); clearInterval(cleanMetroTimer); cleanMetroTimer = null; } - if(metroTimer){ clearTimeout(metroTimer); clearInterval(metroTimer); metroTimer = null; } - if(typeof metroEndpointTimer !== 'undefined' && metroEndpointTimer){ clearTimeout(metroEndpointTimer); metroEndpointTimer = null; } - if(typeof metroEndpointLoopTimer !== 'undefined' && metroEndpointLoopTimer){ clearTimeout(metroEndpointLoopTimer); metroEndpointLoopTimer = null; } - if(typeof metroPendulumFrame !== 'undefined' && metroPendulumFrame){ cancelAnimationFrame(metroPendulumFrame); metroPendulumFrame = null; } - if(typeof metroRafId !== 'undefined' && metroRafId){ cancelAnimationFrame(metroRafId); metroRafId = null; } - } - - function fireBeat(){ - if(!metroRunning) return; - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - clickSound(accented); - renderBeats(); - const v = visual(); - if(v){ - v.classList.remove('beat-hit', 'accent-hit'); - if(accented){ - void v.offsetWidth; - v.classList.add('accent-hit'); - setTimeout(() => v.classList.remove('accent-hit'), 140); - } - } - beatIndex = (beatIndex + 1) % meter; - } - - function frame(serial, now){ - if(!metroRunning || serial !== runSerial) return; - const ms = beatMs(); - const elapsed = Math.max(0, now - runStart); - - // One half-swing per beat: 0=LEFT, 1=RIGHT, 2=LEFT... - const endpoint = Math.floor(elapsed / ms); - const inHalf = (elapsed - endpoint * ms) / ms; - - // Draw position first. At endpoint boundaries this is exactly LEFT or RIGHT. - const angle = -MAX_ANGLE * Math.cos(Math.PI * (endpoint + inHalf)); - drawNeedle(Number(angle.toFixed(3))); - - // First sound is endpoint 1 (RIGHT), never endpoint 0 (LEFT start). - if(endpoint >= 1 && endpoint !== lastEndpoint){ - lastEndpoint = endpoint; - drawNeedle(angleForEndpoint(endpoint)); // exact endpoint before audio/UI trigger - fireBeat(); - } - - rafId = requestAnimationFrame(t => frame(serial, t)); - } - - startMetronome = function(){ - ensureAudio(); - clearRafClock(); - metroRunning = true; - beatIndex = 0; - lastEndpoint = 0; - const serial = runSerial; - const v = visual(); - if(v){ - v.classList.add('running'); - v.classList.remove('beat-hit', 'accent-hit'); - v.style.setProperty('--metro-duration', `${beatMs()}ms`); - } - drawLeftStart(); - renderBeats(); - syncCleanMetronomeUi(); - requestAnimationFrame(t => { - if(!metroRunning || serial !== runSerial) return; - runStart = t; - drawLeftStart(); - rafId = requestAnimationFrame(tt => frame(serial, tt)); - }); - }; - - stopMetronome = function(){ - metroRunning = false; - clearRafClock(); - beatIndex = 0; - const v = visual(); - if(v) v.classList.remove('running', 'beat-hit', 'accent-hit'); - drawLeftStart(); - syncCleanMetronomeUi(); - renderBeats(); - }; - - startPendulumMotion = drawLeftStart; - stopPendulumMotion = function(){ clearRafClock(); drawLeftStart(); }; - tick = fireBeat; - - const previousInit = initMetronome; - initMetronome = function(){ - previousInit(); - if(!metroRunning) requestAnimationFrame(drawLeftStart); - }; -})(); - - -/* EasyTune v52 — metronome sync rewrite. - One RAF draws the needle. Audio, dot and accent light are scheduled ahead for - the exact endpoint time, so they happen when the needle reaches LEFT/RIGHT. - Start/idle is always max LEFT. No frame/circle/border blinking. */ -(function(){ - const LEFT = -1; - const RIGHT = 1; - const MAX_ANGLE = 38; - const LOOKAHEAD_MS = 90; - let rafId = null; - let serial = 0; - let startTs = 0; - let nextEndpoint = 1; // endpoint 0 is the silent LEFT start; endpoint 1 is first audible RIGHT - let scheduledUiTimers = []; - - function beatMs(){ return Math.max(180, 60000 / bpm); } - function angleAt(elapsed){ return -MAX_ANGLE * Math.cos(Math.PI * elapsed / beatMs()); } - function needle(){ return document.querySelector('.metro-clean-pendulum'); } - function visual(){ return document.querySelector('#metroCleanVisual'); } - - function setNeedle(angle){ - const p = needle(); - if(!p) return; - p.style.setProperty('animation', 'none', 'important'); - p.style.setProperty('transition', 'none', 'important'); - p.style.setProperty('transform-origin', '50% 100%', 'important'); - p.style.setProperty('transform', `translateX(-50%) rotate(${angle.toFixed(3)}deg)`, 'important'); - } - - function setLeftStart(){ setNeedle(-MAX_ANGLE); } - - function clearAllMetronomeClocks(){ - serial += 1; - if(rafId){ cancelAnimationFrame(rafId); rafId = null; } - scheduledUiTimers.forEach(id => clearTimeout(id)); - scheduledUiTimers = []; - if(cleanMetroTimer){ clearTimeout(cleanMetroTimer); clearInterval(cleanMetroTimer); cleanMetroTimer = null; } - if(metroTimer){ clearTimeout(metroTimer); clearInterval(metroTimer); metroTimer = null; } - if(typeof metroEndpointTimer !== 'undefined' && metroEndpointTimer){ clearTimeout(metroEndpointTimer); metroEndpointTimer = null; } - if(typeof metroEndpointLoopTimer !== 'undefined' && metroEndpointLoopTimer){ clearTimeout(metroEndpointLoopTimer); metroEndpointLoopTimer = null; } - if(typeof metroPendulumFrame !== 'undefined' && metroPendulumFrame){ cancelAnimationFrame(metroPendulumFrame); metroPendulumFrame = null; } - if(typeof metroRafId !== 'undefined' && metroRafId){ cancelAnimationFrame(metroRafId); metroRafId = null; } - } - - function scheduleClickSound(accented, when){ - ensureAudio(); - const osc = audioCtx.createOscillator(); - const gain = audioCtx.createGain(); - osc.type = 'square'; - osc.frequency.setValueAtTime(accented ? 1300 : 820, when); - gain.gain.setValueAtTime(0.0001, when); - gain.gain.exponentialRampToValueAtTime(accented ? 0.22 : 0.13, when + 0.006); - gain.gain.exponentialRampToValueAtTime(0.0001, when + 0.075); - osc.connect(gain).connect(audioCtx.destination); - osc.start(when); - osc.stop(when + 0.085); - } - - function fireUiBeat(accented){ - if(!metroRunning) return; - renderBeats(); - const v = visual(); - if(v){ - v.classList.remove('beat-hit', 'accent-hit'); - if(accented){ - void v.offsetWidth; - v.classList.add('accent-hit'); - const off = setTimeout(() => v.classList.remove('accent-hit'), 140); - scheduledUiTimers.push(off); - } - } - beatIndex = (beatIndex + 1) % meter; - } - - function scheduleEndpointBeat(endpoint, delayMs){ - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - const audioWhen = audioCtx.currentTime + Math.max(0, delayMs) / 1000; - scheduleClickSound(accented, audioWhen); - const id = setTimeout(() => fireUiBeat(accented), Math.max(0, delayMs)); - scheduledUiTimers.push(id); - } - - function frame(mySerial, now){ - if(!metroRunning || mySerial !== serial) return; - const elapsed = Math.max(0, now - startTs); - setNeedle(angleAt(elapsed)); - - const ms = beatMs(); - while(nextEndpoint * ms - elapsed <= LOOKAHEAD_MS){ - const delay = nextEndpoint * ms - elapsed; - if(nextEndpoint >= 1) scheduleEndpointBeat(nextEndpoint, delay); - nextEndpoint += 1; - } - - rafId = requestAnimationFrame(t => frame(mySerial, t)); - } - - startMetronome = function(){ - ensureAudio(); - clearAllMetronomeClocks(); - metroRunning = true; - beatIndex = 0; - nextEndpoint = 1; - const mySerial = serial; - const v = visual(); - if(v){ - v.classList.add('running'); - v.classList.remove('beat-hit', 'accent-hit'); - v.style.setProperty('--metro-duration', `${beatMs()}ms`); - } - setLeftStart(); - renderBeats(); - syncCleanMetronomeUi(); - requestAnimationFrame(t => { - if(!metroRunning || mySerial !== serial) return; - startTs = t; - setLeftStart(); - rafId = requestAnimationFrame(tt => frame(mySerial, tt)); - }); - }; - - stopMetronome = function(){ - metroRunning = false; - clearAllMetronomeClocks(); - beatIndex = 0; - const v = visual(); - if(v) v.classList.remove('running', 'beat-hit', 'accent-hit'); - setLeftStart(); - syncCleanMetronomeUi(); - renderBeats(); - }; - - startPendulumMotion = setLeftStart; - stopPendulumMotion = function(){ clearAllMetronomeClocks(); setLeftStart(); }; - tick = function(){ - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - clickSound(accented); - fireUiBeat(accented); - }; - - const prevInit = initMetronome; - initMetronome = function(){ - prevInit(); - if(!metroRunning) requestAnimationFrame(setLeftStart); - }; -})(); - -/* EasyTune v53 — metronome perceptual-centre sync. - Needle remains the visual clock. For every endpoint, sound, dot transition and - accent light are started early by half of their own duration, so the CENTER - of the beep / dot transition / flash lands on the exact left/right extreme. - This compensates for fixed animation and click lengths at every BPM. */ -(function(){ - const MAX_ANGLE = 38; - const CLICK_DUR_MS = 85; - const DOT_TRANSITION_MS = 80; - const ACCENT_FLASH_MS = 140; - const SCHED_LOOKAHEAD_MS = 180; - let rafId = null; - let serial = 0; - let startTs = 0; - let nextEndpoint = 1; - let timers = []; - - function beatMs(){ return Math.max(180, 60000 / bpm); } - function needle(){ return document.querySelector('.metro-clean-pendulum'); } - function visual(){ return document.querySelector('#metroCleanVisual'); } - function endpointTimeMs(endpoint){ return endpoint * beatMs(); } - function angleAtElapsed(elapsed){ return -MAX_ANGLE * Math.cos(Math.PI * elapsed / beatMs()); } - function angleForEndpoint(endpoint){ return endpoint % 2 === 0 ? -MAX_ANGLE : MAX_ANGLE; } - - function addTimer(id){ timers.push(id); return id; } - - function setNeedleAngle(angle){ - const p = needle(); - if(!p) return; - p.style.setProperty('animation', 'none', 'important'); - p.style.setProperty('transition', 'none', 'important'); - p.style.setProperty('transform-origin', '50% 100%', 'important'); - p.style.setProperty('transform', `translateX(-50%) rotate(${angle.toFixed(3)}deg)`, 'important'); - } - - function setNeedleLeft(){ setNeedleAngle(-MAX_ANGLE); } - - function clearMetroSync(){ - serial += 1; - if(rafId){ cancelAnimationFrame(rafId); rafId = null; } - timers.forEach(id => clearTimeout(id)); - timers = []; - if(cleanMetroTimer){ clearTimeout(cleanMetroTimer); clearInterval(cleanMetroTimer); cleanMetroTimer = null; } - if(metroTimer){ clearTimeout(metroTimer); clearInterval(metroTimer); metroTimer = null; } - if(typeof metroEndpointTimer !== 'undefined' && metroEndpointTimer){ clearTimeout(metroEndpointTimer); metroEndpointTimer = null; } - if(typeof metroEndpointLoopTimer !== 'undefined' && metroEndpointLoopTimer){ clearTimeout(metroEndpointLoopTimer); metroEndpointLoopTimer = null; } - if(typeof metroPendulumFrame !== 'undefined' && metroPendulumFrame){ cancelAnimationFrame(metroPendulumFrame); metroPendulumFrame = null; } - if(typeof metroRafId !== 'undefined' && metroRafId){ cancelAnimationFrame(metroRafId); metroRafId = null; } - } - - function scheduleAudio(accented, endpointDelayMs){ - ensureAudio(); - const startWhen = audioCtx.currentTime + Math.max(0, endpointDelayMs - CLICK_DUR_MS / 2) / 1000; - const osc = audioCtx.createOscillator(); - const gain = audioCtx.createGain(); - osc.type = 'square'; - osc.frequency.setValueAtTime(accented ? 1300 : 820, startWhen); - gain.gain.setValueAtTime(0.0001, startWhen); - gain.gain.exponentialRampToValueAtTime(accented ? 0.22 : 0.13, startWhen + 0.006); - gain.gain.exponentialRampToValueAtTime(0.0001, startWhen + CLICK_DUR_MS / 1000); - osc.connect(gain).connect(audioCtx.destination); - osc.start(startWhen); - osc.stop(startWhen + (CLICK_DUR_MS + 8) / 1000); - } - - function applyBeatUi(accented){ - if(!metroRunning) return; - renderBeats(); - const v = visual(); - if(v){ - v.classList.remove('beat-hit', 'accent-hit'); - if(accented){ - void v.offsetWidth; - v.classList.add('accent-hit'); - addTimer(setTimeout(() => v.classList.remove('accent-hit'), ACCENT_FLASH_MS)); - } - } - beatIndex = (beatIndex + 1) % meter; - } - - function scheduleEndpoint(endpoint, endpointDelayMs){ - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - scheduleAudio(accented, endpointDelayMs); - - const dotStart = Math.max(0, endpointDelayMs - DOT_TRANSITION_MS / 2); - addTimer(setTimeout(() => applyBeatUi(accented), dotStart)); - } - - function frame(mySerial, now){ - if(!metroRunning || mySerial !== serial) return; - const elapsed = Math.max(0, now - startTs); - setNeedleAngle(angleAtElapsed(elapsed)); - - const endpointDelay = endpointTimeMs(nextEndpoint) - elapsed; - while(endpointDelay <= SCHED_LOOKAHEAD_MS){ - if(nextEndpoint >= 1){ - scheduleEndpoint(nextEndpoint, endpointTimeMs(nextEndpoint) - elapsed); - } - nextEndpoint += 1; - if(endpointTimeMs(nextEndpoint) - elapsed > SCHED_LOOKAHEAD_MS) break; - } - - rafId = requestAnimationFrame(t => frame(mySerial, t)); - } - - startMetronome = function(){ - ensureAudio(); - clearMetroSync(); - metroRunning = true; - beatIndex = 0; - nextEndpoint = 1; - const mySerial = serial; - const v = visual(); - if(v){ - v.classList.add('running'); - v.classList.remove('beat-hit', 'accent-hit'); - v.style.setProperty('--metro-duration', `${beatMs()}ms`); - } - setNeedleLeft(); - renderBeats(); - syncCleanMetronomeUi(); - requestAnimationFrame(t => { - if(!metroRunning || mySerial !== serial) return; - startTs = t; - setNeedleLeft(); - rafId = requestAnimationFrame(tt => frame(mySerial, tt)); - }); - }; - - stopMetronome = function(){ - metroRunning = false; - clearMetroSync(); - beatIndex = 0; - const v = visual(); - if(v) v.classList.remove('running', 'beat-hit', 'accent-hit'); - setNeedleLeft(); - syncCleanMetronomeUi(); - renderBeats(); - }; - - startPendulumMotion = setNeedleLeft; - stopPendulumMotion = function(){ clearMetroSync(); setNeedleLeft(); }; - tick = function(){ - const accented = accentMode === 'all' || (accentMode === 'first' && beatIndex === 0); - scheduleAudio(accented, CLICK_DUR_MS / 2); - applyBeatUi(accented); - }; - - const prevInit = initMetronome; - initMetronome = function(){ - prevInit(); - if(!metroRunning) requestAnimationFrame(setNeedleLeft); - }; -})(); - -/* EasyTune v55 — BPM display button opens Tempo modal */ -(function(){ - var prevInit = initMetronome; - initMetronome = function(){ - prevInit(); - document.querySelector('#metroCleanBpmButton')?.addEventListener('click', function(){ - openMetroSettings('metroTempoModal'); - }); - }; -})(); +loadManifestVersion().then(() => { showApp(); syncInstallButtons(); }); diff --git a/manifest.webmanifest b/manifest.webmanifest index ef16f2b..27fd378 100644 --- a/manifest.webmanifest +++ b/manifest.webmanifest @@ -2,7 +2,7 @@ "name": "EasyTune", "short_name": "EasyTune", "description": "Transpozycja, akordy i metronom dla gitarzystów.", - "version": "1.2", + "version": "1.3", "id": "./", "start_url": "./", "scope": "./", diff --git a/styles.css b/styles.css index 4f6600d..bb8c88a 100644 --- a/styles.css +++ b/styles.css @@ -4690,14 +4690,3 @@ label[for="chordSearch"]{ .metro-clean-visual.accent-hit::before{ animation:metroAccentFlash .14s ease-out 1 !important; } - -/* EasyTune v55 — fix beats container full width + BPM button cursor */ -/* .metro-clean-beats has no explicit width; parent grid's justify-items:center - (set in v39) causes it to shrink-wrap to ~132px. Force full width. */ -.metro-clean-beats { - width: 100% !important; -} -/* BPM display button now opens Tempo modal — restore pointer cursor */ -#metroCleanBpmButton { - cursor: pointer !important; -}