From 70c7dc3d014e22b9d499bb440762981c54e37262 Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Mon, 10 Nov 2025 17:57:36 +0000 Subject: [PATCH] Add synced lyrics support and enhance styling for karaoke mode --- js/app.js | 54 ++++++++++++++++++------ js/lyrics.js | 113 +++++++++++++++++++++++++++++++++++++++++++++------ styles.css | 74 +++++++++++++++++++++++++++++---- 3 files changed, 208 insertions(+), 33 deletions(-) diff --git a/js/app.js b/js/app.js index 62a90dd..bfd5076 100644 --- a/js/app.js +++ b/js/app.js @@ -3,7 +3,7 @@ import { apiSettings, themeManager, nowPlayingSettings } from './storage.js'; import { UIRenderer } from './ui.js'; import { Player } from './player.js'; import { LastFMScrobbler } from './lastfm.js'; -import { LyricsManager, createLyricsPanel, showKaraokeView } from './lyrics.js'; +import { LyricsManager, createLyricsPanel, showKaraokeView, showSyncedLyricsPanel, clearLyricsPanelSync } from './lyrics.js'; import { createRouter, updateTabTitle } from './router.js'; import { initializeSettings } from './settings.js'; import { initializePlayerEvents, initializeTrackInteractions } from './events.js'; @@ -75,7 +75,7 @@ function initializeCasting(audioPlayer, castBtn) { } } -function initializeKeyboardShortcuts(player, audioPlayer) { +function initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel) { document.addEventListener('keydown', (e) => { if (e.target.matches('input, textarea')) return; @@ -128,9 +128,9 @@ function initializeKeyboardShortcuts(player, audioPlayer) { case 'escape': document.getElementById('search-input')?.blur(); document.getElementById('queue-modal-overlay').style.display = 'none'; - const lyricsPanel = document.getElementById('lyrics-panel'); if (lyricsPanel) { lyricsPanel.classList.add('hidden'); + clearLyricsPanelSync(audioPlayer, lyricsPanel); } const karaokeView = document.getElementById('karaoke-view'); if (karaokeView) { @@ -205,7 +205,7 @@ document.addEventListener('DOMContentLoaded', async () => { initializePlayerEvents(player, audioPlayer, scrobbler); initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu')); initializeUIInteractions(player, api); - initializeKeyboardShortcuts(player, audioPlayer); + initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel); initializeMediaSessionHandlers(player); const castBtn = document.getElementById('cast-btn'); @@ -221,6 +221,8 @@ document.addEventListener('DOMContentLoaded', async () => { if (mode === 'karaoke') { lyricsPanel.classList.add('hidden'); + clearLyricsPanelSync(audioPlayer, lyricsPanel); + const lyricsData = await lyricsManager.fetchLyrics(player.currentTrack.id); if (lyricsData) { showKaraokeView(player.currentTrack, lyricsData, audioPlayer); @@ -239,18 +241,13 @@ document.addEventListener('DOMContentLoaded', async () => { if (lyricsData) { lyricsManager.currentLyrics = lyricsData; - - if (lyricsData.lyrics) { - const lines = lyricsData.lyrics.split('\n'); - content.innerHTML = lines.map(line => - `

${line || ' '}

` - ).join(''); - } else { - content.innerHTML = '
No lyrics available
'; - } + showSyncedLyricsPanel(lyricsData, audioPlayer, lyricsPanel); } else { content.innerHTML = '
Failed to load lyrics
'; } + } else { + // Clear sync when hiding + clearLyricsPanelSync(audioPlayer, lyricsPanel); } } }); @@ -258,6 +255,7 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('close-lyrics-btn')?.addEventListener('click', (e) => { e.stopPropagation(); lyricsPanel.classList.add('hidden'); + clearLyricsPanelSync(audioPlayer, lyricsPanel); }); document.getElementById('download-lrc-btn')?.addEventListener('click', (e) => { @@ -271,6 +269,36 @@ document.addEventListener('DOMContentLoaded', async () => { downloadCurrentTrack(player.currentTrack, player.quality, api, lyricsManager); }); + // Auto-update lyrics when track changes + let previousTrackId = null; + audioPlayer.addEventListener('play', async () => { + if (!player.currentTrack) return; + + const currentTrackId = player.currentTrack.id; + if (currentTrackId === previousTrackId) return; + previousTrackId = currentTrackId; + + // Update lyrics panel if it's open + if (!lyricsPanel.classList.contains('hidden')) { + const mode = nowPlayingSettings.getMode(); + if (mode === 'lyrics') { + const content = lyricsPanel.querySelector('.lyrics-content'); + content.innerHTML = '
Loading lyrics...
'; + + const lyricsData = await lyricsManager.fetchLyrics(player.currentTrack.id); + + if (lyricsData) { + lyricsManager.currentLyrics = lyricsData; + // Clear old sync before showing new + clearLyricsPanelSync(audioPlayer, lyricsPanel); + showSyncedLyricsPanel(lyricsData, audioPlayer, lyricsPanel); + } else { + content.innerHTML = '
No lyrics available for this track
'; + } + } + } + }); + document.addEventListener('click', async (e) => { if (e.target.closest('#play-album-btn')) { const btn = e.target.closest('#play-album-btn'); diff --git a/js/lyrics.js b/js/lyrics.js index c319c68..31d0308 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -125,6 +125,74 @@ export function createLyricsPanel() { return panel; } +export function showSyncedLyricsPanel(lyricsData, audioPlayer, panel) { + const content = panel.querySelector('.lyrics-content'); + + const syncedLyrics = lyricsData.subtitles + ? parseSyncedLyricsSimple(lyricsData.subtitles) + : null; + + if (syncedLyrics && syncedLyrics.length > 0) { + // Render synced lyrics + content.innerHTML = ''; + syncedLyrics.forEach((line, index) => { + const lineEl = document.createElement('p'); + lineEl.className = 'lyrics-line synced-line'; + lineEl.textContent = line.text || '♪'; + lineEl.dataset.index = index; + lineEl.dataset.time = line.time; + content.appendChild(lineEl); + }); + + let currentLineIndex = -1; + + const updateLyrics = () => { + const currentTime = audioPlayer.currentTime; + const newIndex = getCurrentLineIndex(syncedLyrics, currentTime); + + if (newIndex !== currentLineIndex) { + currentLineIndex = newIndex; + + content.querySelectorAll('.synced-line').forEach((line, index) => { + line.classList.remove('active', 'upcoming', 'past'); + + if (index === currentLineIndex) { + line.classList.add('active'); + // Smooth scroll to active line + line.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else if (index === currentLineIndex + 1) { + line.classList.add('upcoming'); + } else if (index < currentLineIndex) { + line.classList.add('past'); + } + }); + } + }; + + // Store the update function so we can remove it later + panel.lyricsUpdateHandler = updateLyrics; + audioPlayer.addEventListener('timeupdate', updateLyrics); + + // Initial update + updateLyrics(); + } else if (lyricsData.lyrics) { + // Fallback to static lyrics + const lines = lyricsData.lyrics.split('\n'); + content.innerHTML = lines.map(line => + `

${line || ' '}

` + ).join(''); + } else { + content.innerHTML = '
No lyrics available
'; + } +} + +export function clearLyricsPanelSync(audioPlayer, panel) { + if (panel.lyricsUpdateHandler) { + audioPlayer.removeEventListener('timeupdate', panel.lyricsUpdateHandler); + panel.lyricsUpdateHandler = null; + } +} + export function showKaraokeView(track, lyricsData, audioPlayer) { const view = document.createElement('div'); view.id = 'karaoke-view'; @@ -162,25 +230,44 @@ export function showKaraokeView(track, lyricsData, audioPlayer) { lyricsContainer.appendChild(lineEl); }); - let updateInterval = setInterval(() => { + let currentLineIndex = -1; + + const updateLyrics = () => { const currentTime = audioPlayer.currentTime; - const currentIndex = getCurrentLineIndex(syncedLyrics, currentTime); + const newIndex = getCurrentLineIndex(syncedLyrics, currentTime); - document.querySelectorAll('.karaoke-line').forEach((line, index) => { - line.classList.toggle('active', index === currentIndex); - line.classList.toggle('past', index < currentIndex); - }); - - if (currentIndex >= 0) { - const activeLine = lyricsContainer.children[currentIndex]; - if (activeLine) { - activeLine.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (newIndex !== currentLineIndex) { + currentLineIndex = newIndex; + + document.querySelectorAll('.karaoke-line').forEach((line, index) => { + line.classList.remove('active', 'upcoming', 'past'); + + if (index === currentLineIndex) { + line.classList.add('active'); + } else if (index === currentLineIndex + 1) { + line.classList.add('upcoming'); + } else if (index < currentLineIndex) { + line.classList.add('past'); + } + }); + + if (currentLineIndex >= 0) { + const activeLine = lyricsContainer.children[currentLineIndex]; + if (activeLine) { + activeLine.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } } } - }, 100); + }; + + // Use timeupdate event for better sync + audioPlayer.addEventListener('timeupdate', updateLyrics); + + // Initial update + updateLyrics(); view.querySelector('#close-karaoke-btn').addEventListener('click', () => { - clearInterval(updateInterval); + audioPlayer.removeEventListener('timeupdate', updateLyrics); view.remove(); }); diff --git a/styles.css b/styles.css index e3aacec..df9f4a9 100644 --- a/styles.css +++ b/styles.css @@ -2377,12 +2377,48 @@ input:checked + .slider::before { flex: 1; overflow-y: auto; padding: 1rem; + scroll-behavior: smooth; } .lyrics-line { margin: 0.75rem 0; line-height: 1.6; color: var(--foreground); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Synced lyrics styling with Apple Music animations */ +.synced-line { + padding: 0.5rem 0; + font-size: 1.125rem; + line-height: 1.8; + color: var(--muted-foreground); + opacity: 0.5; + transform: scale(0.95); + filter: blur(0.5px); + transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +.synced-line.active { + color: var(--highlight); + font-weight: 600; + font-size: 1.25rem; + opacity: 1; + transform: scale(1); + filter: blur(0); + text-shadow: 0 0 20px rgba(var(--highlight-rgb), 0.3); +} + +.synced-line.upcoming { + opacity: 0.7; + transform: scale(0.98); + filter: blur(0.3px); +} + +.synced-line.past { + opacity: 0.3; + transform: scale(0.93); + filter: blur(1px); } .lyrics-loading, @@ -2433,28 +2469,44 @@ input:checked + .slider::before { flex-direction: column; align-items: center; gap: 1rem; + scroll-behavior: smooth; } .karaoke-line { font-size: 1.5rem; line-height: 1.8; color: var(--muted-foreground); - transition: all 0.3s ease; + transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; max-width: 800px; - opacity: 0.4; + opacity: 0.35; + transform: scale(0.92); + filter: blur(1px); + padding: 0.5rem 1rem; } .karaoke-line.active { color: var(--highlight); - font-size: 2rem; - font-weight: 600; + font-size: 2.25rem; + font-weight: 700; opacity: 1; - transform: scale(1.1); + transform: scale(1.05); + filter: blur(0); + text-shadow: 0 0 30px rgba(var(--highlight-rgb), 0.4), + 0 0 60px rgba(var(--highlight-rgb), 0.2); + letter-spacing: 0.02em; +} + +.karaoke-line.upcoming { + opacity: 0.55; + transform: scale(0.96); + filter: blur(0.5px); } .karaoke-line.past { - opacity: 0.6; + opacity: 0.25; + transform: scale(0.88); + filter: blur(1.5px); } /* Mobile adjustments */ @@ -2463,6 +2515,14 @@ input:checked + .slider::before { width: 100vw; } + .synced-line { + font-size: 1rem; + } + + .synced-line.active { + font-size: 1.125rem; + } + .karaoke-title { font-size: 1.5rem; } @@ -2476,7 +2536,7 @@ input:checked + .slider::before { } .karaoke-line.active { - font-size: 1.5rem; + font-size: 1.75rem; } }