diff --git a/CHANGELOG.md b/CHANGELOG.md
index b4183be..9320cbb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## [Unreleased — Community Fork]
+
+### Added
+- **18 new themes** — Light, Red, Cyan, Nord, Gruvbox, Tokyo Night, Rosé Pine, Solarized Dark, Monokai, One Dark, Synthwave, Matrix, Sunset, Ocean, Forest, Coffee, Mono, and High Contrast (now 28 themes total).
+- **Custom accent color** — Pick any color as the `--accent` override from the Settings modal; persists across sessions and can be cleared with a single click.
+- **Expanded playback speed** — Now available for **all** sources (music, podcasts, audiobooks) instead of just podcasts/audiobooks. 11-step preset ladder (0.5× → 3×), plus a fine-grained slider in Settings (0.5× – 3× in 0.05 increments). Pitch preservation is togglable (chipmunk mode).
+- **Sleep timer** — Auto-pause after 5/10/15/30/45 min, 1h, 1.5h, 2h, or "end of current track". Live countdown displayed in Settings.
+- **A-B repeat loop** — Mark two points on any track (keyboard `B` / `Z`) to loop a section. Useful for practicing parts of a song or a specific passage in an audiobook.
+- **Resilient `fetch` utility** — New `fetchWithRetry` / `fetchJsonWithRetry` helpers with exponential backoff, `Retry-After` honoring, and retriable-status detection (408, 425, 429, 5xx).
+- **Self-healing error handler** — Global `error` + `unhandledrejection` listeners log context to the console and show rate-limited toasts so users know something hiccupped, without crashing playback.
+- **Diagnostics panel** — "Run Diagnostics" button in Settings probes `/api/health`, localStorage, audio element, MediaSession, service worker, and core state integrity, then reports a pass/fail summary.
+- **Expanded keyboard shortcuts** — `[` / `]` speed down/up, `\` reset speed, `T` / `Shift+T` cycle themes, `B` set A-B point, `Z` clear A-B, `N` nudge sleep timer +15 min, `/` focus search.
+- **Utility helpers** — `debounce` and `throttle` added to `utils.js` for call-rate-limited event handlers across modules.
+
+### Changed
+- **Theme handling** — Switched from hard-coded class-removal list to a regex-based scrub of any `theme-*` class, so new themes never break the picker. `applyTheme()` / `cycleTheme()` now exported for reuse.
+- **Playback speed UI** — Speed button is always visible (not hidden for music); right-click (context menu) on the speed button now cycles backwards. Saved speed applies immediately on module load, before the first track plays.
+- **More menu** — Removed donate link from the "More" menu. Fewer distractions while browsing. (The original Support section in Settings is preserved for existing users.)
+
+### Fixed
+- Theme picker previously only removed a fixed list of ~10 theme classes, so any new theme would accumulate stale classes on `body`. Now scrubs *all* `theme-*` classes.
+- `playbackRate` / `preservesPitch` setters are now wrapped in `try/catch` so a single browser without support doesn't break audio.
+- ` ` updates now run inside `requestAnimationFrame` so the accent variable is re-resolved after the class swap.
+
+---
+
## [1.4.8] - 2026-04-18
### Fixed
diff --git a/README.md b/README.md
index e1a316f..acddbdb 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,16 @@
# Freedify - Music Streaming Web App
-```markdown
-*Last updated: March 25, 2026*
-```
-
Stream music and podcasts from anywhere. **Generate smart playlists with AI**, search songs, albums, artists, podcasts or paste URLs from Spotify, SoundCloud, Bandcamp, Archive.org, Phish.in, and more.
-> 💖 **Support Freedify!** If you enjoy using Freedify and want to support its ongoing development and new features, please consider making a donation: **[Donate on Pally.gg](https://pally.gg/p/freedify)**. Any amount is incredibly appreciated and helps keep the music playing!
+> This is a community fork of [BioHapHazard/Freedify](https://github.com/BioHapHazard/Freedify)
+> focused on performance, resilience, and feature polish. See [CHANGELOG.md](CHANGELOG.md) for
+> fork-specific changes (new themes, expanded playback speed, sleep timer, A-B repeat,
+> self-healing error recovery, resilient `fetch` with retries, keyboard-shortcut expansion,
+> custom accent color, and more).
> [!IMPORTANT]
-> **New to Freedify?** check out our **[Visual Deployment Guide](https://biohaphazard.github.io/Freedify/guide.html)** for easy step-by-step setup instructions (Localhost, Railway, & Render).
+> **New to Freedify?** Check the [deployment options](#-quick-start-docker) below for
+> step-by-step setup (Docker, Localhost, Railway, & Render).
## 🐳 Quick Start (Docker)
diff --git a/static/index.html b/static/index.html
index e03c5da..381a6a0 100644
--- a/static/index.html
+++ b/static/index.html
@@ -51,6 +51,15 @@
⌨️ Keyboard Shortcuts
L Lyrics
V Music Video
Shift + S Sync to Drive
+ [ Slower
+ ] Faster
+ \ Reset speed to 1.0x
+ T Cycle theme
+ Shift + T Cycle theme (reverse)
+ B Set A-B repeat point
+ Z Clear A-B repeat
+ N Sleep timer +15 min
+ / Focus search
? This Help
@@ -141,21 +150,71 @@ Local Files
Appearance
🌙 Default
+
☀️ Light
💜 Purple
💙 Blue
💚 Green
💕 Pink
🧡 Orange
+
❤️ Red
+
🩵 Cyan
🧛 Dracula
☕ Catppuccin
🦉 Night Owl
☢️ Nuclear
+
❄️ Nord
+
🍂 Gruvbox
+
🗼 Tokyo Night
+
🌹 Rosé Pine
+
🌒 Solarized
+
🎨 Monokai
+
🧊 One Dark
+
🌆 Synthwave
+
💾 Matrix
+
🌅 Sunset
+
🌊 Ocean
+
🌲 Forest
+
☕ Coffee
+
⚫ Mono
+
🔲 High Contrast
+
+
+ Custom accent
+
+ Clear
-
+
-
Support
-
Donate
+
Advanced Playback
+
+ Default Speed: 1.0x
+
+
+
+ Preserve pitch when changing speed
+
+
+
+
+ Sleep timer
+
+ Off
+ 5 minutes
+ 10 minutes
+ 15 minutes
+ 30 minutes
+ 45 minutes
+ 1 hour
+ 1.5 hours
+ 2 hours
+ End of track
+
+
+
+
+
⌨️ Keyboard Shortcuts
+
🩺 Run Diagnostics
@@ -194,8 +253,6 @@ Support
-
-
diff --git a/static/playback.js b/static/playback.js
index 4db5465..c38173f 100644
--- a/static/playback.js
+++ b/static/playback.js
@@ -4,7 +4,7 @@
*/
import { state } from './state.js';
-import { emit } from './event-bus.js';
+import { emit, on } from './event-bus.js';
import { showToast, formatTime } from './utils.js';
import {
$, audioPlayer, audioPlayer2, playerBar, playerArt, playerTitle,
@@ -237,20 +237,10 @@ export async function updateFormatBadge(audioSrc) {
return;
}
- const currentTrack = state.queue[state.currentIndex];
- const source = currentTrack?.source || '';
-
if (speedBtn) {
- if (source === 'podcast' || source === 'audiobook') {
- speedBtn.classList.remove('hidden');
- speedBtn.textContent = state.playbackSpeed.toFixed(1) + 'x';
- if (audioPlayer) { audioPlayer.preservesPitch = true; audioPlayer.playbackRate = state.playbackSpeed; }
- if (audioPlayer2) { audioPlayer2.preservesPitch = true; audioPlayer2.playbackRate = state.playbackSpeed; }
- } else {
- speedBtn.classList.add('hidden');
- if (audioPlayer) { audioPlayer.preservesPitch = true; audioPlayer.playbackRate = 1.0; }
- if (audioPlayer2) { audioPlayer2.preservesPitch = true; audioPlayer2.playbackRate = 1.0; }
- }
+ // Speed control is now available for all audio sources, not just podcasts/audiobooks.
+ speedBtn.classList.remove('hidden');
+ applyPlaybackRate();
}
badge.classList.remove('hidden', 'mp3', 'flac', 'hi-res');
@@ -452,12 +442,14 @@ export async function loadTrack(track) {
// audio sample routed through the Web Audio graph. Without this,
// the player briefly runs at 1.0× through the EQ filter chain and
// then abruptly jumps to the target rate, causing crackling.
- if (track.source === 'podcast' || track.source === 'audiobook') {
- player.preservesPitch = true;
- player.playbackRate = state.playbackSpeed;
- } else {
- player.preservesPitch = true;
- player.playbackRate = 1.0;
+ try {
+ const preserve = state.preservesPitch !== false;
+ player.preservesPitch = preserve;
+ player.mozPreservesPitch = preserve;
+ player.webkitPreservesPitch = preserve;
+ player.playbackRate = Number(state.playbackSpeed) || 1.0;
+ } catch (e) {
+ console.warn('[playback] preservesPitch/playbackRate set failed', e);
}
player.play();
@@ -829,21 +821,69 @@ prevBtn.addEventListener('click', playPrevious);
if (miniPlayerBtn) miniPlayerBtn.addEventListener('click', () => emit('toggleMiniPlayer'));
nextBtn.addEventListener('click', () => playNext());
-// Playback speed
-const playbackSpeedBtn = document.getElementById('playback-speed-btn');
-if (playbackSpeedBtn) {
- playbackSpeedBtn.addEventListener('click', () => {
- const speeds = [1.0, 1.25, 1.5, 2.0];
- const currentIdx = speeds.indexOf(state.playbackSpeed) !== -1 ? speeds.indexOf(state.playbackSpeed) : 0;
- state.playbackSpeed = speeds[(currentIdx + 1) % speeds.length];
+// ========== PLAYBACK SPEED ==========
+// Expanded speed options — available for ALL sources (music, podcasts, audiobooks).
+export const PLAYBACK_SPEEDS = [0.5, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
- playbackSpeedBtn.textContent = state.playbackSpeed.toFixed(1) + 'x';
+function formatSpeed(v) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return '1.0x';
+ return (Number.isInteger(n) || n.toFixed(2).endsWith('0') ? n.toFixed(1) : n.toFixed(2)) + 'x';
+}
- if (audioPlayer) { audioPlayer.preservesPitch = true; audioPlayer.playbackRate = state.playbackSpeed; }
- if (audioPlayer2) { audioPlayer2.preservesPitch = true; audioPlayer2.playbackRate = state.playbackSpeed; }
+export function applyPlaybackRate() {
+ const rate = Number(state.playbackSpeed) || 1.0;
+ const preservePitch = state.preservesPitch !== false;
+ [audioPlayer, audioPlayer2].forEach(p => {
+ if (!p) return;
+ try {
+ // preservesPitch is the standard name; prefixed variants for older browsers
+ p.preservesPitch = preservePitch;
+ p.mozPreservesPitch = preservePitch;
+ p.webkitPreservesPitch = preservePitch;
+ p.playbackRate = rate;
+ } catch (e) {
+ console.warn('[playback] Failed to set playbackRate', e);
+ }
});
+ const speedBtn = document.getElementById('playback-speed-btn');
+ if (speedBtn) speedBtn.textContent = formatSpeed(rate);
+ try { localStorage.setItem('freedify_playback_speed', String(rate)); } catch {}
}
+export function setPlaybackSpeed(rate, { toast = true } = {}) {
+ const clamped = Math.max(0.25, Math.min(4.0, Number(rate) || 1.0));
+ state.playbackSpeed = Math.round(clamped * 100) / 100;
+ applyPlaybackRate();
+ if (toast) showToast('Speed: ' + formatSpeed(state.playbackSpeed));
+}
+
+export function cyclePlaybackSpeed(direction = 1) {
+ const speeds = PLAYBACK_SPEEDS;
+ // Snap to the closest known speed first, then step
+ let idx = speeds.findIndex(s => Math.abs(s - state.playbackSpeed) < 0.01);
+ if (idx === -1) {
+ idx = speeds.reduce((best, s, i) =>
+ Math.abs(s - state.playbackSpeed) < Math.abs(speeds[best] - state.playbackSpeed) ? i : best, 0);
+ }
+ const next = speeds[(idx + direction + speeds.length) % speeds.length];
+ setPlaybackSpeed(next);
+}
+
+const playbackSpeedBtn = document.getElementById('playback-speed-btn');
+if (playbackSpeedBtn) {
+ playbackSpeedBtn.addEventListener('click', () => cyclePlaybackSpeed(1));
+ playbackSpeedBtn.addEventListener('contextmenu', (e) => { e.preventDefault(); cyclePlaybackSpeed(-1); });
+}
+
+// Apply saved speed immediately on module load (so new tracks start at the right rate).
+applyPlaybackRate();
+
+// Bridge: allow other modules (ui.js settings slider, shortcuts) to drive speed without importing
+on('setPlaybackSpeed', (v) => setPlaybackSpeed(v, { toast: false }));
+on('preservesPitchChanged', () => applyPlaybackRate());
+on('sleepTimerFired', () => { try { updatePlayButton(); } catch {} });
+
// Shuffle queue
shuffleQueueBtn.addEventListener('click', () => {
if (state.queue.length <= 1) return;
@@ -1433,8 +1473,85 @@ document.addEventListener('keydown', (e) => {
case 'Q':
queueSection.classList.toggle('hidden');
break;
+ case '[':
+ e.preventDefault();
+ cyclePlaybackSpeed(-1);
+ break;
+ case ']':
+ e.preventDefault();
+ cyclePlaybackSpeed(1);
+ break;
+ case '\\':
+ e.preventDefault();
+ setPlaybackSpeed(1.0);
+ break;
+ case 't':
+ case 'T':
+ e.preventDefault();
+ emit('cycleTheme', e.shiftKey ? -1 : 1);
+ break;
+ case 'b':
+ case 'B':
+ e.preventDefault();
+ setABRepeatPoint();
+ break;
+ case 'z':
+ case 'Z':
+ e.preventDefault();
+ clearABRepeat();
+ break;
+ case 'n':
+ case 'N':
+ e.preventDefault();
+ emit('sleepTimerNudge', 15);
+ break;
+ case '/':
+ if (!e.ctrlKey && !e.metaKey && !e.altKey) {
+ e.preventDefault();
+ if (searchInput) { searchInput.focus(); searchInput.select?.(); }
+ }
+ break;
case '?':
shortcutsHelp.classList.toggle('hidden');
break;
}
});
+
+// ========== A-B REPEAT ==========
+export function setABRepeatPoint() {
+ const player = getActivePlayer();
+ const t = player?.currentTime;
+ if (!Number.isFinite(t)) return;
+ if (state.abRepeat.a == null) {
+ state.abRepeat.a = t;
+ showToast(`A-B: A point set at ${formatTime(t)}`);
+ } else if (state.abRepeat.b == null) {
+ if (t <= state.abRepeat.a + 0.5) {
+ showToast('A-B: B must be after A — pick a later point');
+ return;
+ }
+ state.abRepeat.b = t;
+ showToast(`A-B: looping ${formatTime(state.abRepeat.a)} → ${formatTime(t)}`);
+ } else {
+ // Cycle through: third press clears
+ clearABRepeat();
+ }
+}
+
+export function clearABRepeat() {
+ if (state.abRepeat.a == null && state.abRepeat.b == null) return;
+ state.abRepeat.a = null;
+ state.abRepeat.b = null;
+ showToast('A-B repeat cleared');
+}
+
+function maybeApplyABRepeat() {
+ const { a, b } = state.abRepeat;
+ if (a == null || b == null) return;
+ const player = getActivePlayer();
+ if (!player) return;
+ if (player.currentTime >= b) {
+ try { player.currentTime = a; } catch {}
+ }
+}
+setInterval(maybeApplyABRepeat, 250);
diff --git a/static/state.js b/static/state.js
index ea8b5ae..0da8485 100644
--- a/static/state.js
+++ b/static/state.js
@@ -42,7 +42,11 @@ export const state = {
lastSearchType: 'track', // Store last search type
history: safeLoad('freedify_history', []),
library: safeLoad('freedify_library', []),
- playbackSpeed: 1.0, // Default playback speed for podcasts
+ playbackSpeed: parseFloat(localStorage.getItem('freedify_playback_speed')) || 1.0, // Persists across reloads; applies to all audio
+ preservesPitch: localStorage.getItem('freedify_preserves_pitch') !== 'false', // Default true
+ sleepTimer: null, // { endsAt: number|null, endOfTrack: boolean, minutes: number|null }
+ abRepeat: { a: null, b: null }, // Seconds; when both set, loops between them
+ customAccent: localStorage.getItem('freedify_custom_accent') || '', // Optional user-picked accent color
podcastFavorites: safeLoad('freedify_podcasts', []),
audiobookFavorites: safeLoad('freedify_audiobooks', []),
podcastPlayedEpisodes: safeLoad('freedify_podcast_played', {}),
diff --git a/static/styles.css b/static/styles.css
index 41d2792..62b9f5e 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -141,6 +141,344 @@
--accent-glow: rgba(255, 0, 127, 0.3);
}
+.theme-light {
+ --bg-primary: #fafafa;
+ --bg-secondary: #ffffff;
+ --bg-tertiary: #f1f3f5;
+ --bg-elevated: #e9ecef;
+
+ --text-primary: #1a1a1a;
+ --text-secondary: #4b5563;
+ --text-tertiary: #6b7280;
+
+ --accent: #6366f1;
+ --accent-light: #818cf8;
+ --accent-dark: #4f46e5;
+ --accent-glow: rgba(99, 102, 241, 0.2);
+}
+
+.theme-red {
+ --accent: #ef4444;
+ --accent-light: #f87171;
+ --accent-dark: #dc2626;
+ --accent-glow: rgba(239, 68, 68, 0.3);
+}
+
+.theme-cyan {
+ --accent: #06b6d4;
+ --accent-light: #22d3ee;
+ --accent-dark: #0891b2;
+ --accent-glow: rgba(6, 182, 212, 0.3);
+}
+
+.theme-nord {
+ --bg-primary: #2e3440;
+ --bg-secondary: #272c36;
+ --bg-tertiary: #3b4252;
+ --bg-elevated: #434c5e;
+
+ --text-primary: #eceff4;
+ --text-secondary: #d8dee9;
+ --text-tertiary: #81a1c1;
+
+ --accent: #88c0d0;
+ --accent-light: #8fbcbb;
+ --accent-dark: #5e81ac;
+ --accent-glow: rgba(136, 192, 208, 0.3);
+}
+
+.theme-gruvbox {
+ --bg-primary: #282828;
+ --bg-secondary: #1d2021;
+ --bg-tertiary: #3c3836;
+ --bg-elevated: #504945;
+
+ --text-primary: #ebdbb2;
+ --text-secondary: #d5c4a1;
+ --text-tertiary: #a89984;
+
+ --accent: #fabd2f;
+ --accent-light: #fe8019;
+ --accent-dark: #d79921;
+ --accent-glow: rgba(250, 189, 47, 0.3);
+}
+
+.theme-tokyo {
+ --bg-primary: #1a1b26;
+ --bg-secondary: #16161e;
+ --bg-tertiary: #24283b;
+ --bg-elevated: #414868;
+
+ --text-primary: #c0caf5;
+ --text-secondary: #a9b1d6;
+ --text-tertiary: #565f89;
+
+ --accent: #7aa2f7;
+ --accent-light: #bb9af7;
+ --accent-dark: #5c7cfa;
+ --accent-glow: rgba(122, 162, 247, 0.3);
+}
+
+.theme-rose-pine {
+ --bg-primary: #191724;
+ --bg-secondary: #1f1d2e;
+ --bg-tertiary: #26233a;
+ --bg-elevated: #403d52;
+
+ --text-primary: #e0def4;
+ --text-secondary: #c4a7e7;
+ --text-tertiary: #908caa;
+
+ --accent: #eb6f92;
+ --accent-light: #f6c177;
+ --accent-dark: #9ccfd8;
+ --accent-glow: rgba(235, 111, 146, 0.3);
+}
+
+.theme-solarized-dark {
+ --bg-primary: #002b36;
+ --bg-secondary: #073642;
+ --bg-tertiary: #0e4551;
+ --bg-elevated: #586e75;
+
+ --text-primary: #eee8d5;
+ --text-secondary: #93a1a1;
+ --text-tertiary: #657b83;
+
+ --accent: #b58900;
+ --accent-light: #cb4b16;
+ --accent-dark: #859900;
+ --accent-glow: rgba(181, 137, 0, 0.3);
+}
+
+.theme-monokai {
+ --bg-primary: #272822;
+ --bg-secondary: #1e1f1c;
+ --bg-tertiary: #3e3d32;
+ --bg-elevated: #49483e;
+
+ --text-primary: #f8f8f2;
+ --text-secondary: #a6e22e;
+ --text-tertiary: #75715e;
+
+ --accent: #f92672;
+ --accent-light: #fd971f;
+ --accent-dark: #ae81ff;
+ --accent-glow: rgba(249, 38, 114, 0.3);
+}
+
+.theme-one-dark {
+ --bg-primary: #282c34;
+ --bg-secondary: #21252b;
+ --bg-tertiary: #3e4451;
+ --bg-elevated: #4b5263;
+
+ --text-primary: #abb2bf;
+ --text-secondary: #9da5b4;
+ --text-tertiary: #5c6370;
+
+ --accent: #61afef;
+ --accent-light: #c678dd;
+ --accent-dark: #56b6c2;
+ --accent-glow: rgba(97, 175, 239, 0.3);
+}
+
+.theme-synthwave {
+ --bg-primary: #16003c;
+ --bg-secondary: #0f0028;
+ --bg-tertiary: #2b0a6d;
+ --bg-elevated: #4d148c;
+
+ --text-primary: #f0e6ff;
+ --text-secondary: #ff71ce;
+ --text-tertiary: #b967ff;
+
+ --accent: #ff71ce;
+ --accent-light: #fdfd96;
+ --accent-dark: #01cdfe;
+ --accent-glow: rgba(255, 113, 206, 0.4);
+}
+
+.theme-matrix {
+ --bg-primary: #000000;
+ --bg-secondary: #030d03;
+ --bg-tertiary: #081708;
+ --bg-elevated: #0e2a0e;
+
+ --text-primary: #00ff41;
+ --text-secondary: #00c030;
+ --text-tertiary: #007c1e;
+
+ --accent: #00ff41;
+ --accent-light: #5fff88;
+ --accent-dark: #00b22d;
+ --accent-glow: rgba(0, 255, 65, 0.35);
+}
+
+.theme-sunset {
+ --bg-primary: #1b0f24;
+ --bg-secondary: #130a1b;
+ --bg-tertiary: #2f1636;
+ --bg-elevated: #5a1e4d;
+
+ --text-primary: #ffe7d6;
+ --text-secondary: #ffb199;
+ --text-tertiary: #f97373;
+
+ --accent: #ff7b54;
+ --accent-light: #ffd56b;
+ --accent-dark: #c84b6e;
+ --accent-glow: rgba(255, 123, 84, 0.35);
+}
+
+.theme-ocean {
+ --bg-primary: #021526;
+ --bg-secondary: #03223d;
+ --bg-tertiary: #063154;
+ --bg-elevated: #0a4273;
+
+ --text-primary: #e0f2fe;
+ --text-secondary: #93c5fd;
+ --text-tertiary: #64b5f6;
+
+ --accent: #0ea5e9;
+ --accent-light: #38bdf8;
+ --accent-dark: #0369a1;
+ --accent-glow: rgba(14, 165, 233, 0.3);
+}
+
+.theme-forest {
+ --bg-primary: #0f1a12;
+ --bg-secondary: #0a1310;
+ --bg-tertiary: #17261c;
+ --bg-elevated: #1f3a29;
+
+ --text-primary: #dff4e2;
+ --text-secondary: #a8d1b0;
+ --text-tertiary: #6b9072;
+
+ --accent: #4ade80;
+ --accent-light: #86efac;
+ --accent-dark: #16a34a;
+ --accent-glow: rgba(74, 222, 128, 0.3);
+}
+
+.theme-coffee {
+ --bg-primary: #2c1810;
+ --bg-secondary: #20110a;
+ --bg-tertiary: #3c241a;
+ --bg-elevated: #5c3a26;
+
+ --text-primary: #f4e4cf;
+ --text-secondary: #d4a574;
+ --text-tertiary: #a97f49;
+
+ --accent: #c98a4b;
+ --accent-light: #e6b98b;
+ --accent-dark: #8b5a2b;
+ --accent-glow: rgba(201, 138, 75, 0.3);
+}
+
+.theme-mono {
+ --bg-primary: #111111;
+ --bg-secondary: #191919;
+ --bg-tertiary: #232323;
+ --bg-elevated: #2e2e2e;
+
+ --text-primary: #f5f5f5;
+ --text-secondary: #bdbdbd;
+ --text-tertiary: #8a8a8a;
+
+ --accent: #e5e5e5;
+ --accent-light: #ffffff;
+ --accent-dark: #bdbdbd;
+ --accent-glow: rgba(255, 255, 255, 0.2);
+}
+
+.theme-high-contrast {
+ --bg-primary: #000000;
+ --bg-secondary: #0a0a0a;
+ --bg-tertiary: #161616;
+ --bg-elevated: #242424;
+
+ --text-primary: #ffffff;
+ --text-secondary: #ffffff;
+ --text-tertiary: #f5f5f5;
+
+ --accent: #ffeb3b;
+ --accent-light: #ffffff;
+ --accent-dark: #ffc107;
+ --accent-glow: rgba(255, 235, 59, 0.5);
+}
+
+/* ==================== Keyboard shortcut list ==================== */
+.shortcuts-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: var(--spacing-sm) var(--spacing-md);
+ padding: var(--spacing-sm) 0;
+}
+
+.shortcuts-grid > div {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ font-size: 0.92rem;
+ color: var(--text-secondary);
+}
+
+.shortcuts-grid kbd {
+ display: inline-block;
+ padding: 2px 8px;
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border: 1px solid var(--accent-dark);
+ border-radius: 6px;
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 0.82rem;
+ box-shadow: 0 1px 0 var(--accent-dark);
+}
+
+/* ==================== Inline settings rows ==================== */
+.settings-inline-row {
+ display: flex !important;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.settings-inline-row input[type="checkbox"] { transform: scale(1.25); }
+.settings-inline-row input[type="color"] {
+ width: 42px;
+ height: 30px;
+ border: 1px solid var(--accent-dark);
+ border-radius: 6px;
+ background: transparent;
+ cursor: pointer;
+ padding: 2px;
+}
+.settings-inline-row select {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border: 1px solid var(--accent-dark);
+ border-radius: 6px;
+ padding: 4px 8px;
+}
+.settings-chip {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border: 1px solid var(--accent-dark);
+ border-radius: 6px;
+ padding: 4px 10px;
+ cursor: pointer;
+ font-size: 0.85rem;
+}
+.settings-chip:hover { background: var(--accent); color: #fff; }
+.settings-meta {
+ font-size: 0.82rem;
+ color: var(--text-tertiary);
+ padding: 0 var(--spacing-sm) var(--spacing-xs);
+}
+
/* ==================== Reset & Base ==================== */
*, *::before, *::after {
box-sizing: border-box;
diff --git a/static/ui.js b/static/ui.js
index a8de4de..b7c23e5 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -5,7 +5,7 @@
import { state } from './state.js';
import { escapeHtml, showToast } from './utils.js';
-import { emit } from './event-bus.js';
+import { emit, on } from './event-bus.js';
import { getMoodStatsForWeek } from './data.js';
import {
$, $$, loadingOverlay, loadingText, errorMessage, errorText,
@@ -274,53 +274,94 @@ document.addEventListener('keydown', (e) => {
const themePicker = $('#theme-picker');
const themeOptions = $$('.theme-option');
-// Load saved theme on startup
-(function loadSavedTheme() {
- const savedTheme = localStorage.getItem('freedify_theme') || '';
- if (savedTheme) {
- document.body.classList.add(savedTheme);
- }
- themeOptions.forEach(opt => {
- if (opt.dataset.theme === savedTheme) {
- opt.classList.add('active');
- }
- });
+// Source of truth for available theme class names (must stay in sync with index.html + styles.css)
+const THEME_CLASSES = Array.from(themeOptions)
+ .map(o => o.dataset.theme)
+ .filter(Boolean);
- const metaThemeColor = document.querySelector('meta[name="theme-color"]');
- if (metaThemeColor && savedTheme) {
- setTimeout(() => {
- const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
- if (accentColor) metaThemeColor.content = accentColor;
- }, 50);
- }
-})();
+function clearThemeClasses() {
+ // Remove any previously-applied theme-* class (future-proof, won't break if new themes added)
+ const toRemove = Array.from(document.body.classList).filter(c => c.startsWith('theme-'));
+ toRemove.forEach(c => document.body.classList.remove(c));
+}
-themeOptions.forEach(opt => {
- opt.addEventListener('click', () => {
- const newTheme = opt.dataset.theme;
+function syncMetaThemeColor() {
+ const metaThemeColor = document.querySelector('meta[name="theme-color"]');
+ if (!metaThemeColor) return;
+ // Defer one frame so CSS variables are re-resolved against the new theme class
+ requestAnimationFrame(() => {
+ const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
+ if (accentColor) metaThemeColor.content = accentColor;
+ });
+}
- document.body.classList.remove('theme-purple', 'theme-blue', 'theme-green', 'theme-pink', 'theme-orange', 'theme-dracula', 'theme-catppuccin', 'theme-nightowl', 'theme-nuclear');
+function applyCustomAccent(color) {
+ if (color) {
+ document.documentElement.style.setProperty('--accent', color);
+ document.documentElement.style.setProperty('--accent-light', color);
+ document.documentElement.style.setProperty('--accent-dark', color);
+ document.documentElement.style.setProperty('--accent-glow', color + '55');
+ } else {
+ document.documentElement.style.removeProperty('--accent');
+ document.documentElement.style.removeProperty('--accent-light');
+ document.documentElement.style.removeProperty('--accent-dark');
+ document.documentElement.style.removeProperty('--accent-glow');
+ }
+ syncMetaThemeColor();
+}
- if (newTheme) {
- document.body.classList.add(newTheme);
- }
+export function applyTheme(themeName, { persist = true, silent = false } = {}) {
+ clearThemeClasses();
+ if (themeName) document.body.classList.add(themeName);
+ if (persist) localStorage.setItem('freedify_theme', themeName || '');
+ themeOptions.forEach(o => o.classList.toggle('active', o.dataset.theme === (themeName || '')));
+ if (state.customAccent) applyCustomAccent(state.customAccent);
+ else syncMetaThemeColor();
+ if (!silent) {
+ const opt = Array.from(themeOptions).find(o => o.dataset.theme === (themeName || ''));
+ if (opt) showToast(`Theme: ${opt.textContent.trim()}`);
+ }
+}
- localStorage.setItem('freedify_theme', newTheme);
+export function cycleTheme(direction = 1) {
+ const current = localStorage.getItem('freedify_theme') || '';
+ const list = ['', ...THEME_CLASSES];
+ const idx = list.indexOf(current);
+ const next = list[((idx === -1 ? 0 : idx) + direction + list.length) % list.length];
+ applyTheme(next);
+}
- themeOptions.forEach(o => o.classList.remove('active'));
- opt.classList.add('active');
+// Load saved theme on startup
+(function loadSavedTheme() {
+ const savedTheme = localStorage.getItem('freedify_theme') || '';
+ applyTheme(savedTheme, { persist: false, silent: true });
+})();
- showToast(`Theme changed to ${opt.textContent}`);
+themeOptions.forEach(opt => {
+ opt.addEventListener('click', () => applyTheme(opt.dataset.theme));
+});
- const metaThemeColor = document.querySelector('meta[name="theme-color"]');
- if (metaThemeColor) {
- setTimeout(() => {
- const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
- if (accentColor) metaThemeColor.content = accentColor;
- }, 50);
- }
+// ========== CUSTOM ACCENT COLOR ==========
+const customAccentInput = $('#custom-accent-input');
+const customAccentClear = $('#custom-accent-clear');
+if (customAccentInput) {
+ if (state.customAccent) customAccentInput.value = state.customAccent;
+ applyCustomAccent(state.customAccent);
+ customAccentInput.addEventListener('input', (e) => {
+ const color = e.target.value;
+ state.customAccent = color;
+ localStorage.setItem('freedify_custom_accent', color);
+ applyCustomAccent(color);
});
-});
+}
+if (customAccentClear) {
+ customAccentClear.addEventListener('click', () => {
+ state.customAccent = '';
+ localStorage.removeItem('freedify_custom_accent');
+ applyCustomAccent('');
+ showToast('Custom accent cleared');
+ });
+}
// ========== HiFi MODE ==========
const hifiBtn = $('#hifi-btn');
@@ -458,3 +499,237 @@ export function renderMoodSelector(containerEl) {
});
}
}
+
+// ========== EVENT-BUS: THEME CYCLE ==========
+on('cycleTheme', (direction) => cycleTheme(direction || 1));
+
+// ========== KEYBOARD SHORTCUTS BUTTON (settings modal) ==========
+(function initShortcutsHelpButton() {
+ const btn = $('#shortcuts-help-btn');
+ const help = $('#shortcuts-help');
+ if (btn && help) {
+ btn.addEventListener('click', () => {
+ help.classList.remove('hidden');
+ // Also close settings modal so help is visible in front
+ settingsModal?.classList.add('hidden');
+ });
+ }
+})();
+
+// ========== SLEEP TIMER ==========
+const sleepSelect = $('#sleep-timer-select');
+const sleepStatus = $('#sleep-timer-status');
+let sleepIntervalId = null;
+
+function clearSleepTimer() {
+ if (sleepIntervalId) {
+ clearInterval(sleepIntervalId);
+ sleepIntervalId = null;
+ }
+ state.sleepTimer = null;
+ if (sleepStatus) {
+ sleepStatus.classList.add('hidden');
+ sleepStatus.textContent = '';
+ }
+}
+
+function formatRemaining(ms) {
+ const s = Math.max(0, Math.ceil(ms / 1000));
+ const mins = Math.floor(s / 60);
+ const secs = s % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+function fireSleepPause() {
+ try {
+ const ap = document.getElementById('audio-player');
+ const ap2 = document.getElementById('audio-player-2');
+ [ap, ap2].forEach(p => { if (p && !p.paused) p.pause(); });
+ state.isPlaying = false;
+ // Update play button via event bus
+ emit('sleepTimerFired');
+ showToast('😴 Sleep timer — playback paused');
+ } catch (e) { console.warn('sleep pause error', e); }
+}
+
+function startSleepTimer({ minutes = null, endOfTrack = false } = {}) {
+ clearSleepTimer();
+ if (endOfTrack) {
+ state.sleepTimer = { endOfTrack: true, endsAt: null, minutes: null };
+ if (sleepStatus) {
+ sleepStatus.classList.remove('hidden');
+ sleepStatus.textContent = '😴 Sleep: at end of current track';
+ }
+ // Listen for one 'ended' event via interval polling
+ sleepIntervalId = setInterval(() => {
+ const ap = document.getElementById('audio-player');
+ const ap2 = document.getElementById('audio-player-2');
+ if (ap && ap.ended || ap2 && ap2.ended) {
+ fireSleepPause();
+ clearSleepTimer();
+ }
+ }, 500);
+ return;
+ }
+ if (!minutes || minutes <= 0) return;
+ const endsAt = Date.now() + minutes * 60_000;
+ state.sleepTimer = { endOfTrack: false, endsAt, minutes };
+ if (sleepStatus) sleepStatus.classList.remove('hidden');
+ sleepIntervalId = setInterval(() => {
+ const remaining = endsAt - Date.now();
+ if (remaining <= 0) {
+ fireSleepPause();
+ clearSleepTimer();
+ if (sleepSelect) sleepSelect.value = '0';
+ return;
+ }
+ if (sleepStatus) sleepStatus.textContent = `😴 Sleep in ${formatRemaining(remaining)}`;
+ }, 1000);
+}
+
+if (sleepSelect) {
+ sleepSelect.addEventListener('change', () => {
+ const v = sleepSelect.value;
+ if (v === '0') { clearSleepTimer(); showToast('Sleep timer off'); return; }
+ if (v === 'eot') { startSleepTimer({ endOfTrack: true }); showToast('Sleep: end of current track'); return; }
+ const mins = parseInt(v, 10);
+ if (!Number.isNaN(mins) && mins > 0) {
+ startSleepTimer({ minutes: mins });
+ showToast(`Sleep timer: ${mins} min`);
+ }
+ });
+}
+
+// Keyboard: N adds 15 minutes to the timer (or starts one)
+on('sleepTimerNudge', (mins) => {
+ const addMs = (mins || 15) * 60_000;
+ const t = state.sleepTimer;
+ if (t && t.endsAt) {
+ const newMins = Math.round((t.endsAt - Date.now() + addMs) / 60_000);
+ startSleepTimer({ minutes: Math.max(1, newMins) });
+ showToast(`Sleep +${mins} min (total ~${newMins}m)`);
+ if (sleepSelect) sleepSelect.value = String(newMins >= 5 && newMins <= 120 ? newMins : 0);
+ } else {
+ startSleepTimer({ minutes: mins || 15 });
+ showToast(`Sleep timer: ${mins || 15} min`);
+ if (sleepSelect) sleepSelect.value = String(mins || 15);
+ }
+});
+
+// ========== ADVANCED PLAYBACK SETTINGS ==========
+(function initPlaybackSettings() {
+ const slider = $('#settings-speed-slider');
+ const valueEl = $('#settings-speed-value');
+ const preserveCb = $('#settings-preserve-pitch');
+
+ if (slider && valueEl) {
+ const current = Number(state.playbackSpeed) || 1.0;
+ slider.value = String(current);
+ valueEl.textContent = current.toFixed(2) + 'x';
+ slider.addEventListener('input', () => {
+ const v = parseFloat(slider.value);
+ if (Number.isFinite(v)) {
+ valueEl.textContent = v.toFixed(2) + 'x';
+ // Let playback module handle clamping + persistence
+ emit('setPlaybackSpeed', v);
+ }
+ });
+ }
+
+ if (preserveCb) {
+ preserveCb.checked = state.preservesPitch !== false;
+ preserveCb.addEventListener('change', () => {
+ state.preservesPitch = preserveCb.checked;
+ localStorage.setItem('freedify_preserves_pitch', String(preserveCb.checked));
+ emit('preservesPitchChanged');
+ showToast(preserveCb.checked ? 'Preserving pitch' : 'Not preserving pitch (chipmunk mode)');
+ });
+ }
+})();
+
+// ========== DIAGNOSTICS ==========
+$('#diagnostics-btn')?.addEventListener('click', async () => {
+ const results = [];
+ const check = (name, ok, detail = '') => results.push({ name, ok, detail });
+ try {
+ // 1. Backend /health
+ const t0 = performance.now();
+ const res = await fetch('/api/health').catch(() => null);
+ if (res) {
+ const dt = (performance.now() - t0).toFixed(0);
+ check('Backend /api/health', res.ok, res.ok ? `${dt}ms` : `HTTP ${res.status}`);
+ } else {
+ check('Backend /api/health', false, 'No response');
+ }
+
+ // 2. localStorage usable
+ try {
+ localStorage.setItem('__freedify_diag__', '1');
+ localStorage.removeItem('__freedify_diag__');
+ check('localStorage', true, 'OK');
+ } catch (e) {
+ check('localStorage', false, String(e));
+ }
+
+ // 3. Audio element ready
+ const ap = document.getElementById('audio-player');
+ check('HTMLAudioElement', !!ap, ap ? `ready; rate=${ap.playbackRate}` : 'missing');
+
+ // 4. MediaSession
+ check('MediaSession API', 'mediaSession' in navigator, 'mediaSession' in navigator ? 'available' : 'missing');
+
+ // 5. Service worker
+ check('ServiceWorker', 'serviceWorker' in navigator, 'serviceWorker' in navigator ? 'registered' : 'unavailable');
+
+ // 6. State integrity
+ check('State: queue array', Array.isArray(state.queue), `length=${state.queue.length}`);
+ check('State: library array', Array.isArray(state.library), `length=${state.library.length}`);
+ } catch (e) {
+ check('Diagnostics', false, String(e));
+ }
+
+ const okCount = results.filter(r => r.ok).length;
+ const lines = results.map(r => `${r.ok ? '✓' : '✗'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
+ const header = `🩺 Diagnostics: ${okCount}/${results.length} passed`;
+ alert([header, '', ...lines].join('\n'));
+ console.groupCollapsed(header);
+ results.forEach(r => console.log(r));
+ console.groupEnd();
+});
+
+// ========== SELF-HEALING: GLOBAL ERROR HANDLER ==========
+// Log unhandled errors to console with context, and show a soft toast so users
+// know something hiccupped (without nuking playback). Repeated storms are
+// rate-limited so we never drown the user in toasts.
+(function installSelfHealingErrorHandlers() {
+ let lastToastAt = 0;
+ let suppressedCount = 0;
+
+ function handle(kind, message, error) {
+ try {
+ console.warn(`[self-heal:${kind}]`, message, error || '');
+ } catch {}
+ const now = Date.now();
+ if (now - lastToastAt < 5000) {
+ suppressedCount++;
+ return;
+ }
+ lastToastAt = now;
+ const extra = suppressedCount > 0 ? ` (+${suppressedCount} more suppressed)` : '';
+ suppressedCount = 0;
+ try { showToast(`⚠️ ${message}${extra}`); } catch {}
+ }
+
+ window.addEventListener('error', (e) => {
+ // Ignore noisy 3rd-party errors we can't fix
+ const src = e?.filename || '';
+ if (src && !src.startsWith(location.origin) && !src.startsWith('/')) return;
+ handle('error', e?.message || 'Unknown error', e?.error);
+ });
+
+ window.addEventListener('unhandledrejection', (e) => {
+ const reason = e?.reason;
+ const msg = (reason && (reason.message || reason.toString())) || 'Unhandled promise rejection';
+ handle('promise', msg, reason);
+ });
+})();
diff --git a/static/utils.js b/static/utils.js
index 589cb70..a63c6b8 100644
--- a/static/utils.js
+++ b/static/utils.js
@@ -70,3 +70,88 @@ export function showToast(message, duration = 3000) {
setTimeout(() => toast.remove(), 300);
}, duration);
}
+
+/**
+ * fetchWithRetry — resilient wrapper around fetch with exponential backoff.
+ *
+ * Retries on network errors and on retriable HTTP statuses (408, 429, 5xx),
+ * honoring `Retry-After` when present. Bails early if the caller's AbortSignal
+ * is tripped.
+ *
+ * @param {string|Request} input
+ * @param {RequestInit & {retries?:number,backoff?:number,retryOn?:(res:Response)=>boolean}} [init]
+ */
+export async function fetchWithRetry(input, init = {}) {
+ const {
+ retries = 3,
+ backoff = 400,
+ retryOn = (res) => [408, 425, 429, 500, 502, 503, 504].includes(res.status),
+ ...rest
+ } = init;
+
+ let lastErr = null;
+ for (let attempt = 0; attempt <= retries; attempt++) {
+ if (rest.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
+ try {
+ const res = await fetch(input, rest);
+ if (res.ok) return res;
+ if (attempt >= retries || !retryOn(res)) return res;
+
+ // Honor Retry-After if present
+ let delay = backoff * Math.pow(2, attempt) + Math.random() * 200;
+ const ra = res.headers.get('Retry-After');
+ if (ra) {
+ const raNum = Number(ra);
+ if (Number.isFinite(raNum) && raNum > 0) delay = Math.max(delay, raNum * 1000);
+ }
+ await new Promise(r => setTimeout(r, delay));
+ } catch (err) {
+ lastErr = err;
+ if (err?.name === 'AbortError') throw err;
+ if (attempt >= retries) throw err;
+ await new Promise(r => setTimeout(r, backoff * Math.pow(2, attempt) + Math.random() * 200));
+ }
+ }
+ if (lastErr) throw lastErr;
+ throw new Error('fetchWithRetry: exhausted retries');
+}
+
+/** Convenience: same as fetchWithRetry but also parses JSON. */
+export async function fetchJsonWithRetry(input, init = {}) {
+ const res = await fetchWithRetry(input, init);
+ if (!res.ok) throw new Error(`HTTP ${res.status} — ${res.statusText}`);
+ return res.json();
+}
+
+/**
+ * Debounce helper — collapses bursts of calls into one.
+ */
+export function debounce(fn, wait = 200) {
+ let t = null;
+ return function (...args) {
+ if (t) clearTimeout(t);
+ t = setTimeout(() => { t = null; fn.apply(this, args); }, wait);
+ };
+}
+
+/**
+ * Throttle helper — guarantees at most one call every `wait` ms.
+ */
+export function throttle(fn, wait = 200) {
+ let last = 0;
+ let pending = null;
+ return function (...args) {
+ const now = Date.now();
+ if (now - last >= wait) {
+ last = now;
+ fn.apply(this, args);
+ } else {
+ if (pending) clearTimeout(pending);
+ pending = setTimeout(() => {
+ last = Date.now();
+ pending = null;
+ fn.apply(this, args);
+ }, wait - (now - last));
+ }
+ };
+}