remove pitch changing and allow more granular playback speed control, remember what setting tab youre on
This commit is contained in:
parent
63e9a71456
commit
ea005c68ad
6 changed files with 113 additions and 182 deletions
68
index.html
68
index.html
|
|
@ -3105,66 +3105,40 @@
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Playback Speed</span>
|
<span class="label">Playback Speed</span>
|
||||||
<span class="description">Adjust playback speed (0.5x - 2.0x)</span>
|
<span class="description">Adjust playback speed (0.01x - 100x)</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 12px">
|
<div style="display: flex; align-items: center; gap: 12px">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
id="playback-speed-slider"
|
id="playback-speed-slider"
|
||||||
min="0.5"
|
min="0.25"
|
||||||
max="2.0"
|
max="4.0"
|
||||||
step="0.1"
|
step="0.01"
|
||||||
value="1.0"
|
value="1.0"
|
||||||
style="width: 150px"
|
style="width: 150px"
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
id="playback-speed-value"
|
|
||||||
style="min-width: 50px; text-align: right; font-family: var(--font-family)"
|
|
||||||
>1.0x</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pitch Shift Control -->
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="info">
|
|
||||||
<span class="label">Pitch Shift</span>
|
|
||||||
<span class="description"
|
|
||||||
>Shift pitch up or down (-12 to +12 semitones). Note: Disable "Preserve
|
|
||||||
Pitch" to hear the effect</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; align-items: center; gap: 12px">
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="number"
|
||||||
id="pitch-shift-slider"
|
id="playback-speed-input"
|
||||||
min="-12"
|
min="0.01"
|
||||||
max="12"
|
max="100"
|
||||||
step="1"
|
step="0.01"
|
||||||
value="0"
|
value="1.0"
|
||||||
style="width: 150px"
|
style="
|
||||||
|
width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<span
|
<span style="font-family: var(--font-family)">x</span>
|
||||||
id="pitch-shift-value"
|
|
||||||
style="min-width: 50px; text-align: right; font-family: var(--font-family)"
|
|
||||||
>0</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="info">
|
|
||||||
<span class="label">Preserve Pitch</span>
|
|
||||||
<span class="description"
|
|
||||||
>Keep original pitch when changing playback speed</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="preserve-pitch-toggle" checked />
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 16-Band Equalizer -->
|
<!-- 16-Band Equalizer -->
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
|
|
|
||||||
|
|
@ -95,16 +95,6 @@ class AudioContextManager {
|
||||||
this._loadSettings();
|
this._loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate playback rate for pitch shift
|
|
||||||
* @param {number} semitones - Pitch shift in semitones (-12 to +12)
|
|
||||||
* @returns {number} - Playback rate multiplier
|
|
||||||
*/
|
|
||||||
getPitchRate(semitones) {
|
|
||||||
// Convert semitones to playback rate: rate = 2^(semitones/12)
|
|
||||||
return Math.pow(2, semitones / 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a callback to be called when audio graph is reconnected
|
* Register a callback to be called when audio graph is reconnected
|
||||||
* @param {Function} callback - Function to call when graph changes
|
* @param {Function} callback - Function to call when graph changes
|
||||||
|
|
|
||||||
43
js/player.js
43
js/player.js
|
|
@ -117,54 +117,17 @@ export class Player {
|
||||||
|
|
||||||
applyAudioEffects() {
|
applyAudioEffects() {
|
||||||
const speed = audioEffectsSettings.getSpeed();
|
const speed = audioEffectsSettings.getSpeed();
|
||||||
const pitchShift = audioEffectsSettings.getPitch();
|
if (this.audio.playbackRate !== speed) {
|
||||||
const preservePitch = audioEffectsSettings.getPreservePitch();
|
this.audio.playbackRate = speed;
|
||||||
|
|
||||||
// Calculate pitch rate: 2^(semitones/12)
|
|
||||||
const pitchRate = Math.pow(2, pitchShift / 12);
|
|
||||||
|
|
||||||
// When preservePitch is enabled, playbackRate only affects speed
|
|
||||||
// When disabled, playbackRate affects both speed and pitch
|
|
||||||
// To shift pitch without changing speed (when preservePitch is off),
|
|
||||||
// we need to compensate the speed
|
|
||||||
if (preservePitch) {
|
|
||||||
// Pitch is preserved, playbackRate controls speed only
|
|
||||||
if (this.audio.playbackRate !== speed) {
|
|
||||||
this.audio.playbackRate = speed;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// playbackRate affects both speed and pitch
|
|
||||||
// Combine speed and pitch: finalRate = speed * pitchRate
|
|
||||||
const finalRate = speed * pitchRate;
|
|
||||||
if (this.audio.playbackRate !== finalRate) {
|
|
||||||
this.audio.playbackRate = finalRate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply pitch preservation setting
|
|
||||||
if (this.audio.preservesPitch !== preservePitch) {
|
|
||||||
this.audio.preservesPitch = preservePitch;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setPlaybackSpeed(speed) {
|
setPlaybackSpeed(speed) {
|
||||||
const validSpeed = Math.max(0.5, Math.min(2.0, parseFloat(speed) || 1.0));
|
const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0));
|
||||||
audioEffectsSettings.setSpeed(validSpeed);
|
audioEffectsSettings.setSpeed(validSpeed);
|
||||||
this.applyAudioEffects();
|
this.applyAudioEffects();
|
||||||
}
|
}
|
||||||
|
|
||||||
setPitchShift(semitones) {
|
|
||||||
const validPitch = Math.max(-12, Math.min(12, parseInt(semitones, 10) || 0));
|
|
||||||
audioEffectsSettings.setPitch(validPitch);
|
|
||||||
// For now, pitch shift is informational only
|
|
||||||
// Full implementation would require Web Audio API pitch shifting
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreservePitch(enabled) {
|
|
||||||
audioEffectsSettings.setPreservePitch(enabled);
|
|
||||||
this.applyAudioEffects();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadQueueState() {
|
loadQueueState() {
|
||||||
const savedState = queueManager.getQueue();
|
const savedState = queueManager.getQueue();
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
monoAudioSettings,
|
monoAudioSettings,
|
||||||
exponentialVolumeSettings,
|
exponentialVolumeSettings,
|
||||||
audioEffectsSettings,
|
audioEffectsSettings,
|
||||||
|
settingsUiState,
|
||||||
pwaUpdateSettings,
|
pwaUpdateSettings,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
||||||
|
|
@ -37,6 +38,16 @@ import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js';
|
import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js';
|
||||||
|
|
||||||
export function initializeSettings(scrobbler, player, api, ui) {
|
export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
|
// Restore last active settings tab
|
||||||
|
const savedTab = settingsUiState.getActiveTab();
|
||||||
|
const settingsTab = document.querySelector(`.settings-tab[data-tab="${savedTab}"]`);
|
||||||
|
if (settingsTab) {
|
||||||
|
document.querySelectorAll('.settings-tab').forEach((t) => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.settings-tab-content').forEach((c) => c.classList.remove('active'));
|
||||||
|
settingsTab.classList.add('active');
|
||||||
|
document.getElementById(`settings-tab-${savedTab}`)?.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize account system UI & Settings
|
// Initialize account system UI & Settings
|
||||||
authManager.updateUI(authManager.user);
|
authManager.updateUI(authManager.user);
|
||||||
|
|
||||||
|
|
@ -789,40 +800,37 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Audio Effects (Playback Speed & Pitch)
|
// Audio Effects (Playback Speed)
|
||||||
// ========================================
|
// ========================================
|
||||||
const playbackSpeedSlider = document.getElementById('playback-speed-slider');
|
const playbackSpeedSlider = document.getElementById('playback-speed-slider');
|
||||||
const playbackSpeedValue = document.getElementById('playback-speed-value');
|
const playbackSpeedInput = document.getElementById('playback-speed-input');
|
||||||
if (playbackSpeedSlider && playbackSpeedValue) {
|
if (playbackSpeedSlider && playbackSpeedInput) {
|
||||||
playbackSpeedSlider.value = audioEffectsSettings.getSpeed();
|
const currentSpeed = audioEffectsSettings.getSpeed();
|
||||||
playbackSpeedValue.textContent = playbackSpeedSlider.value + 'x';
|
// Clamp slider to its range (0.25-4), but show actual value in input
|
||||||
|
playbackSpeedSlider.value = Math.max(0.25, Math.min(4.0, currentSpeed));
|
||||||
|
playbackSpeedInput.value = currentSpeed;
|
||||||
|
|
||||||
|
// Slider only controls 0.25-4 range
|
||||||
playbackSpeedSlider.addEventListener('input', (e) => {
|
playbackSpeedSlider.addEventListener('input', (e) => {
|
||||||
const speed = e.target.value;
|
const speed = parseFloat(e.target.value) || 1.0;
|
||||||
playbackSpeedValue.textContent = speed + 'x';
|
playbackSpeedInput.value = speed;
|
||||||
player.setPlaybackSpeed(speed);
|
player.setPlaybackSpeed(speed);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const pitchShiftSlider = document.getElementById('pitch-shift-slider');
|
// Input allows full 0.01-100 range
|
||||||
const pitchShiftValue = document.getElementById('pitch-shift-value');
|
const handleInputChange = () => {
|
||||||
if (pitchShiftSlider && pitchShiftValue) {
|
const speed = parseFloat(playbackSpeedInput.value) || 1.0;
|
||||||
pitchShiftSlider.value = audioEffectsSettings.getPitch();
|
const validSpeed = Math.max(0.01, Math.min(100, speed));
|
||||||
pitchShiftValue.textContent = (pitchShiftSlider.value > 0 ? '+' : '') + pitchShiftSlider.value;
|
playbackSpeedInput.value = validSpeed;
|
||||||
|
// Only update slider if value is within slider range
|
||||||
|
if (validSpeed >= 0.25 && validSpeed <= 4.0) {
|
||||||
|
playbackSpeedSlider.value = validSpeed;
|
||||||
|
}
|
||||||
|
player.setPlaybackSpeed(validSpeed);
|
||||||
|
};
|
||||||
|
|
||||||
pitchShiftSlider.addEventListener('input', (e) => {
|
playbackSpeedInput.addEventListener('change', handleInputChange);
|
||||||
const pitch = e.target.value;
|
playbackSpeedInput.addEventListener('blur', handleInputChange);
|
||||||
pitchShiftValue.textContent = (pitch > 0 ? '+' : '') + pitch;
|
|
||||||
player.setPitchShift(pitch);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const preservePitchToggle = document.getElementById('preserve-pitch-toggle');
|
|
||||||
if (preservePitchToggle) {
|
|
||||||
preservePitchToggle.checked = audioEffectsSettings.getPreservePitch();
|
|
||||||
preservePitchToggle.addEventListener('change', (e) => {
|
|
||||||
player.setPreservePitch(e.target.checked);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -2034,7 +2042,7 @@ function filterSettings(query) {
|
||||||
const allTabs = settingsPage.querySelectorAll('.settings-tab');
|
const allTabs = settingsPage.querySelectorAll('.settings-tab');
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
// Reset: show active tab only
|
// Reset: show saved active tab
|
||||||
allTabContents.forEach((content) => {
|
allTabContents.forEach((content) => {
|
||||||
content.classList.remove('active');
|
content.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
@ -2042,12 +2050,17 @@ function filterSettings(query) {
|
||||||
tab.classList.remove('active');
|
tab.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore first tab as active
|
// Restore saved tab as active
|
||||||
const firstTab = allTabs[0];
|
const savedTabName = settingsUiState.getActiveTab();
|
||||||
const firstContent = allTabContents[0];
|
const savedTab = document.querySelector(`.settings-tab[data-tab="${savedTabName}"]`);
|
||||||
if (firstTab && firstContent) {
|
const savedContent = document.getElementById(`settings-tab-${savedTabName}`);
|
||||||
firstTab.classList.add('active');
|
if (savedTab && savedContent) {
|
||||||
firstContent.classList.add('active');
|
savedTab.classList.add('active');
|
||||||
|
savedContent.classList.add('active');
|
||||||
|
} else if (allTabs[0] && allTabContents[0]) {
|
||||||
|
// Fallback to first tab if saved tab not found
|
||||||
|
allTabs[0].classList.add('active');
|
||||||
|
allTabContents[0].classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show all settings groups and items
|
// Show all settings groups and items
|
||||||
|
|
|
||||||
|
|
@ -897,78 +897,36 @@ export const exponentialVolumeSettings = {
|
||||||
|
|
||||||
export const audioEffectsSettings = {
|
export const audioEffectsSettings = {
|
||||||
SPEED_KEY: 'audio-effects-speed',
|
SPEED_KEY: 'audio-effects-speed',
|
||||||
PITCH_KEY: 'audio-effects-pitch',
|
|
||||||
PRESERVE_PITCH_KEY: 'audio-effects-preserve-pitch',
|
|
||||||
|
|
||||||
// Playback speed (0.5 to 2.0, default 1.0)
|
// Playback speed (0.01 to 100, default 1.0)
|
||||||
getSpeed() {
|
getSpeed() {
|
||||||
try {
|
try {
|
||||||
const val = parseFloat(localStorage.getItem(this.SPEED_KEY));
|
const val = parseFloat(localStorage.getItem(this.SPEED_KEY));
|
||||||
return isNaN(val) ? 1.0 : Math.max(0.5, Math.min(2.0, val));
|
return isNaN(val) ? 1.0 : Math.max(0.01, Math.min(100, val));
|
||||||
} catch {
|
} catch {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setSpeed(speed) {
|
setSpeed(speed) {
|
||||||
const validSpeed = Math.max(0.5, Math.min(2.0, parseFloat(speed) || 1.0));
|
const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0));
|
||||||
localStorage.setItem(this.SPEED_KEY, validSpeed.toString());
|
localStorage.setItem(this.SPEED_KEY, validSpeed.toString());
|
||||||
},
|
},
|
||||||
|
|
||||||
// Pitch shift (-12 to +12 semitones, default 0)
|
|
||||||
getPitch() {
|
|
||||||
try {
|
|
||||||
const val = parseInt(localStorage.getItem(this.PITCH_KEY), 10);
|
|
||||||
return isNaN(val) ? 0 : Math.max(-12, Math.min(12, val));
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setPitch(pitch) {
|
|
||||||
const validPitch = Math.max(-12, Math.min(12, parseInt(pitch, 10) || 0));
|
|
||||||
localStorage.setItem(this.PITCH_KEY, validPitch.toString());
|
|
||||||
},
|
|
||||||
|
|
||||||
// Preserve pitch when changing speed (default true)
|
|
||||||
getPreservePitch() {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(this.PRESERVE_PITCH_KEY) !== 'false';
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setPreservePitch(enabled) {
|
|
||||||
localStorage.setItem(this.PRESERVE_PITCH_KEY, enabled ? 'true' : 'false');
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sidebarSettings = {
|
export const settingsUiState = {
|
||||||
STORAGE_KEY: 'monochrome-sidebar-collapsed',
|
ACTIVE_TAB_KEY: 'settings-active-tab',
|
||||||
|
|
||||||
isCollapsed() {
|
getActiveTab() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(this.STORAGE_KEY) === 'true';
|
return localStorage.getItem(this.ACTIVE_TAB_KEY) || 'appearance';
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return 'appearance';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setCollapsed(collapsed) {
|
setActiveTab(tab) {
|
||||||
localStorage.setItem(this.STORAGE_KEY, collapsed ? 'true' : 'false');
|
localStorage.setItem(this.ACTIVE_TAB_KEY, tab);
|
||||||
},
|
|
||||||
|
|
||||||
restoreState() {
|
|
||||||
const isCollapsed = this.isCollapsed();
|
|
||||||
if (isCollapsed) {
|
|
||||||
document.body.classList.add('sidebar-collapsed');
|
|
||||||
const toggleBtn = document.getElementById('sidebar-toggle');
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.innerHTML =
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1002,6 +960,34 @@ export const queueManager = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sidebarSettings = {
|
||||||
|
STORAGE_KEY: 'monochrome-sidebar-collapsed',
|
||||||
|
|
||||||
|
isCollapsed() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.STORAGE_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setCollapsed(collapsed) {
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, collapsed ? 'true' : 'false');
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreState() {
|
||||||
|
const isCollapsed = this.isCollapsed();
|
||||||
|
if (isCollapsed) {
|
||||||
|
document.body.classList.add('sidebar-collapsed');
|
||||||
|
const toggleBtn = document.getElementById('sidebar-toggle');
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.innerHTML =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const listenBrainzSettings = {
|
export const listenBrainzSettings = {
|
||||||
ENABLED_KEY: 'listenbrainz-enabled',
|
ENABLED_KEY: 'listenbrainz-enabled',
|
||||||
TOKEN_KEY: 'listenbrainz-token',
|
TOKEN_KEY: 'listenbrainz-token',
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,11 @@ export function initializeUIInteractions(player, api, ui) {
|
||||||
|
|
||||||
const contentId = `settings-tab-${tab.dataset.tab}`;
|
const contentId = `settings-tab-${tab.dataset.tab}`;
|
||||||
document.getElementById(contentId)?.classList.add('active');
|
document.getElementById(contentId)?.classList.add('active');
|
||||||
|
|
||||||
|
// Save active tab
|
||||||
|
import('./storage.js').then(({ settingsUiState }) => {
|
||||||
|
settingsUiState.setActiveTab(tab.dataset.tab);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue