From 586464d729765c67e893de7e4b96b119f433b2cc Mon Sep 17 00:00:00 2001 From: Devin Date: Tue, 21 Apr 2026 07:02:49 +0000 Subject: [PATCH] feat(ui,playback): themes, sleep timer, A-B repeat, self-healing, diagnostics - Add 18 new themes (Light, Red, Cyan, Nord, Gruvbox, Tokyo Night, Rose Pine, Solarized Dark, Monokai, One Dark, Synthwave, Matrix, Sunset, Ocean, Forest, Coffee, Mono, High Contrast) for a total of 28 themes. - Add custom accent color picker with persistence and clear action. - Expand playback speed to all sources (music, podcasts, audiobooks) with an 11-step ladder (0.5x-3x) and a Settings slider (0.05 step). Preserve-pitch toggle (chipmunk mode). - Add sleep timer: 5/10/15/30/45 min, 1h, 1.5h, 2h, or end-of-track, with live countdown and keyboard nudge (+15 min via N). - Add A-B repeat loop (B set, Z clear) with 250ms boundary watchdog. - Add resilient fetchWithRetry / fetchJsonWithRetry in utils (exponential backoff, Retry-After, retriable-status detection). Add debounce/throttle. - Add self-healing global error handler (rate-limited toasts, console context for window.error and unhandledrejection). - Add diagnostics panel probing /api/health, localStorage, audio element, MediaSession, service worker, and state integrity. - Expand keyboard shortcuts: [ / ] speed, \ reset, T/Shift+T theme cycle, B/Z A-B repeat, N sleep +15, / focus search. - Theme picker now regex-scrubs any theme-* class so new themes do not accumulate stale classes on body. - Speed button always visible; right-click cycles backwards. - Wrap preservesPitch/playbackRate in try/catch so unsupported browsers do not break audio. - meta[name=theme-color] updated inside rAF so CSS vars are resolved first. - Remove donate link from the 'More' menu (Support section in Settings kept). - Update README to reflect community fork focus and link CHANGELOG. --- CHANGELOG.md | 26 ++++ README.md | 13 +- static/index.html | 67 ++++++++- static/playback.js | 177 +++++++++++++++++++---- static/state.js | 6 +- static/styles.css | 338 +++++++++++++++++++++++++++++++++++++++++++ static/ui.js | 353 ++++++++++++++++++++++++++++++++++++++++----- static/utils.js | 85 +++++++++++ 8 files changed, 984 insertions(+), 81 deletions(-) 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

LLyrics
VMusic Video
Shift + SSync to Drive
+
[Slower
+
]Faster
+
\Reset speed to 1.0x
+
TCycle theme
+
Shift + TCycle theme (reverse)
+
BSet A-B repeat point
+
ZClear A-B repeat
+
NSleep 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
+
+
+ + +
- +
-

Support

- Donate +

Advanced Playback

+
+ + +
+ +
+
+ + +
+ +
+ +
@@ -194,8 +253,6 @@

Support

- - Donate 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)); + } + }; +}