-
-
Save ngocjohn/bf1e84abb94cb0189348e67c4a91c1a1 to your computer and use it in GitHub Desktop.
Real-time Synced Lyrics for Home Assistant Media Players (No Spotify/API Keys Required)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ========================================================================================= | |
| # HOME ASSISTANT DASHBOARD CARD (Synched Lyrics) | |
| # ----------------------------------------------------------------------------------------- | |
| # INSTRUCTIONS: | |
| # 1. Replace 'media_player.YOUR_ENTITY_ID' with your actual media player. | |
| # 2. Add this as a 'Manual' card in your Lovelace dashboard. | |
| # ========================================================================================= | |
| type: conditional | |
| conditions: | |
| - condition: state | |
| entity: media_player.YOUR_ENTITY_ID # <-- Change this to your Echo, Sonos, etc. | |
| state: playing | |
| card: | |
| type: iframe | |
| url: /local/lyrics.html | |
| aspect_ratio: '100%' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ========================================================================================= | |
| # UNIVERSAL SYNCED LYRICS FOR HOME ASSISTANT | |
| # ----------------------------------------------------------------------------------------- | |
| # This script fetches synced lyrics from LRCLib and calculates playback position locally. | |
| # | |
| # INSTRUCTIONS: | |
| # 1. Replace 'media_player.YOUR_MEDIA_PLAYER' with your actual entity ID. | |
| # 2. Update 'self.output_path' if your HA config uses a different structure. | |
| # ========================================================================================= | |
| import appdaemon.plugins.hass.hassapi as hass | |
| import urllib.request | |
| import urllib.parse | |
| import json | |
| import time | |
| import datetime | |
| class KitchenLyrics(hass.Hass): | |
| def initialize(self): | |
| # Path to store the JSON data for the HTML frontend | |
| self.output_path = "/homeassistant/www/lyrics_data.json" | |
| self.current_track = None | |
| # Change 'media_player.YOUR_MEDIA_PLAYER' to your actual media player entity | |
| self.player_entity = "media_player.YOUR_MEDIA_PLAYER" | |
| self.listen_state(self.on_player_change, self.player_entity) | |
| self.listen_state(self.on_player_change, self.player_entity, attribute="media_title") | |
| self.log("KitchenLyrics: initialized") | |
| # Check current state immediately on startup | |
| self.run_in(self.check_initial_state, 2) | |
| def check_initial_state(self, kwargs): | |
| self.on_player_change(None, None, None, None, {}) | |
| def on_player_change(self, entity, attribute, old, new, kwargs): | |
| # Debounce — wait 3s for metadata to settle after a track change | |
| self.run_in(self.do_fetch, 3) | |
| def do_fetch(self, kwargs): | |
| state = self.get_state(self.player_entity, attribute="all") | |
| attrs = state.get("attributes", {}) | |
| player_state = state.get("state", "idle") | |
| if player_state != "playing": | |
| self.write_empty() | |
| self.current_track = None | |
| return | |
| artist = attrs.get("media_artist", "") | |
| title = attrs.get("media_title", "") | |
| duration = attrs.get("media_duration", 0) | |
| media_position = attrs.get("media_position", 0) | |
| media_position_updated_at = attrs.get("media_position_updated_at", "") | |
| entity_picture = attrs.get("entity_picture", "") | |
| track_key = f"{artist}|{title}" | |
| is_new_track = track_key != self.current_track | |
| # Calculate start time epoch based on position | |
| try: | |
| updated_dt = datetime.datetime.fromisoformat( | |
| media_position_updated_at.replace("Z", "+00:00") | |
| ) | |
| updated_epoch = updated_dt.timestamp() | |
| # 1.0s offset compensates for LRCLib timing lag | |
| start_epoch = updated_epoch - media_position - 1.0 | |
| except Exception: | |
| start_epoch = time.time() | |
| if is_new_track: | |
| self.current_track = track_key | |
| synced_lyrics = self.fetch_lyrics(artist, title, duration) | |
| lines = self.parse_lrc(synced_lyrics) if synced_lyrics else None | |
| self._cached_lines = lines | |
| else: | |
| lines = getattr(self, "_cached_lines", None) | |
| data = { | |
| "artist": artist, | |
| "title": title, | |
| "start_time": start_epoch, | |
| "entity_picture": entity_picture, | |
| "lines": lines, | |
| "has_lyrics": lines is not None and len(lines) > 0 | |
| } | |
| self.write_data(data) | |
| def fetch_lyrics(self, artist, title, duration): | |
| try: | |
| params = urllib.parse.urlencode({ | |
| "artist_name": artist, | |
| "track_name": title, | |
| "duration": int(duration) | |
| }) | |
| url = f"https://lrclib.net/api/get?{params}" | |
| req = urllib.request.Request( | |
| url, | |
| headers={"User-Agent": "HomeAssistantLyricsEngine/1.0"} | |
| ) | |
| with urllib.request.urlopen(req, timeout=10) as r: | |
| result = json.loads(r.read()) | |
| return result.get("syncedLyrics") | |
| except Exception as e: | |
| self.log(f"KitchenLyrics: LRCLib fetch failed: {e}") | |
| return None | |
| def parse_lrc(self, lrc_text): | |
| lines = [] | |
| for line in lrc_text.strip().split("\n"): | |
| line = line.strip() | |
| if not line or not line.startswith("["): | |
| continue | |
| try: | |
| bracket_end = line.index("]") | |
| timestamp = line[1:bracket_end] | |
| text = line[bracket_end + 1:].strip() | |
| parts = timestamp.split(":") | |
| if len(parts) == 2: | |
| minutes = float(parts[0]) | |
| seconds = float(parts[1]) | |
| total_seconds = minutes * 60 + seconds | |
| lines.append({"time": total_seconds, "text": text}) | |
| except Exception: | |
| continue | |
| return lines | |
| def write_data(self, data): | |
| try: | |
| with open(self.output_path, "w") as f: | |
| json.dump(data, f) | |
| except Exception as e: | |
| self.log(f"KitchenLyrics: failed to write data: {e}") | |
| def write_empty(self): | |
| self.write_data({ | |
| "artist": "", "title": "", "start_time": 0, | |
| "entity_picture": "", "lines": None, "has_lyrics": False | |
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ========================================================================================= | |
| # APPDAEMON CONFIGURATION | |
| # ----------------------------------------------------------------------------------------- | |
| # Place this file in your AppDaemon apps directory: | |
| # /addon_configs/YOUR_APPDAEMON_ID/apps/ | |
| # ========================================================================================= | |
| kitchen_lyrics: | |
| module: kitchen_lyrics | |
| class: KitchenLyrics |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!--UNIVERSAL SYNCED LYRICS FRONTEND for Home AssistantThis page polls 'lyrics_data.json' (generated by AppDaemon) and calculates | |
| real-time lyric scrolling using client-side math (Date.now()).PLACEMENT: | |
| Save this file in your Home Assistant directory as: /config/www/lyrics.html Access via: your-ha-url:8123/local/lyrics.html DASHBOARD CONFIGURATION:type: conditional | |
| conditions:condition: state entity: media_player.kitchen_echo_spot state: playing | |
| card: | |
| type: iframe url: /local/lyrics.html aspect_ratio: '100%' | |
| ========================================================================================= | |
| --> | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Lyrics</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
| background: #0a0a0a; | |
| color: #fff; | |
| height: 100vh; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| #bg { | |
| position: absolute; | |
| inset: 0; | |
| background-size: cover; | |
| background-position: center; | |
| filter: blur(40px) brightness(0.35) saturate(1.8); | |
| transform: scale(1.1); | |
| transition: background-image 1.5s ease; | |
| z-index: 0; | |
| } | |
| #container { | |
| position: relative; | |
| z-index: 1; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| #header { | |
| padding: 14px 18px 10px; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| flex-shrink: 0; | |
| background: linear-gradient(to bottom, rgba(0,0,0,0.5), transparent); | |
| } | |
| #album-art { | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 6px; | |
| object-fit: cover; | |
| opacity: 0; | |
| transition: opacity 0.5s ease; | |
| flex-shrink: 0; | |
| } | |
| #album-art.loaded { opacity: 1; } | |
| #track-info { flex: 1; min-width: 0; } | |
| #title { | |
| font-size: 14px; | |
| font-weight: 700; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| color: #fff; | |
| } | |
| #artist { | |
| font-size: 12px; | |
| color: rgba(255,255,255,0.6); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| margin-top: 2px; | |
| } | |
| #lyrics-container { | |
| flex: 1; | |
| overflow: hidden; | |
| position: relative; | |
| padding: 0 18px; | |
| mask-image: linear-gradient( | |
| to bottom, | |
| transparent 0%, | |
| black 15%, | |
| black 75%, | |
| transparent 100% | |
| ); | |
| -webkit-mask-image: linear-gradient( | |
| to bottom, | |
| transparent 0%, | |
| black 15%, | |
| black 75%, | |
| transparent 100% | |
| ); | |
| } | |
| #lyrics-inner { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); | |
| padding: 60px 0; | |
| } | |
| .lyric-line { | |
| font-size: 15px; | |
| font-weight: 600; | |
| line-height: 1.4; | |
| color: rgba(255,255,255,0.25); | |
| transition: color 0.4s ease, font-size 0.4s ease, transform 0.4s ease; | |
| cursor: default; | |
| text-align: left; | |
| } | |
| .lyric-line.active { | |
| color: #ffffff; | |
| font-size: 18px; | |
| transform: translateX(4px); | |
| } | |
| .lyric-line.near { | |
| color: rgba(255,255,255,0.55); | |
| font-size: 16px; | |
| } | |
| .lyric-line.empty { opacity: 0; height: 8px; } | |
| #no-lyrics { | |
| display: none; | |
| position: absolute; | |
| inset: 0; | |
| align-items: center; | |
| justify-content: center; | |
| flex-direction: column; | |
| gap: 8px; | |
| color: rgba(255,255,255,0.3); | |
| font-size: 13px; | |
| text-align: center; | |
| } | |
| #no-lyrics.visible { display: flex; } | |
| #no-lyrics .icon { | |
| font-size: 32px; | |
| margin-bottom: 4px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="bg"></div> | |
| <div id="container"> | |
| <div id="header"> | |
| <img id="album-art" src="" alt=""> | |
| <div id="track-info"> | |
| <div id="title">—</div> | |
| <div id="artist"></div> | |
| </div> | |
| </div> | |
| <div id="lyrics-container"> | |
| <div id="lyrics-inner"></div> | |
| <div id="no-lyrics"> | |
| <div class="icon">🎵</div> | |
| <div>No lyrics found for this track</div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const DATA_URL = '/local/lyrics_data.json'; | |
| const HA_BASE = window.location.origin; | |
| let currentTrack = null; | |
| let lines = []; | |
| let lineElements = []; | |
| let activeIndex = -1; | |
| let animFrame = null; | |
| let lastDataCheck = 0; | |
| const bg = document.getElementById('bg'); | |
| const albumArt = document.getElementById('album-art'); | |
| const titleEl = document.getElementById('title'); | |
| const artistEl = document.getElementById('artist'); | |
| const lyricsInner = document.getElementById('lyrics-inner'); | |
| const noLyrics = document.getElementById('no-lyrics'); | |
| async function fetchData() { | |
| try { | |
| const r = await fetch(DATA_URL + '?t=' + Date.now()); | |
| return await r.json(); | |
| } catch (e) { | |
| return null; | |
| } | |
| } | |
| function loadTrack(data) { | |
| const key = `${data.artist}|${data.title}`; | |
| if (key === currentTrack) return; | |
| currentTrack = key; | |
| titleEl.textContent = data.title || '—'; | |
| artistEl.textContent = data.artist || ''; | |
| // Album art + background | |
| if (data.entity_picture) { | |
| const imgUrl = data.entity_picture.startsWith('http') | |
| ? data.entity_picture | |
| : HA_BASE + data.entity_picture; | |
| albumArt.src = imgUrl; | |
| albumArt.onload = () => albumArt.classList.add('loaded'); | |
| albumArt.classList.remove('loaded'); | |
| bg.style.backgroundImage = `url('${imgUrl}')`; | |
| } | |
| // Build lyric lines | |
| lyricsInner.innerHTML = ''; | |
| lineElements = []; | |
| activeIndex = -1; | |
| if (!data.has_lyrics || !data.lines || data.lines.length === 0) { | |
| noLyrics.classList.add('visible'); | |
| return; | |
| } | |
| noLyrics.classList.remove('visible'); | |
| lines = data.lines; | |
| lines.forEach((line, i) => { | |
| const el = document.createElement('div'); | |
| el.className = 'lyric-line' + (line.text === '' ? ' empty' : ''); | |
| el.textContent = line.text; | |
| lyricsInner.appendChild(el); | |
| lineElements.push(el); | |
| }); | |
| } | |
| function tick(data) { | |
| if (!data || !data.has_lyrics || !data.lines || data.lines.length === 0) return; | |
| const now = Date.now() / 1000; | |
| const elapsed = now - data.start_time; | |
| // Find current line | |
| let current = -1; | |
| for (let i = 0; i < lines.length; i++) { | |
| if (lines[i].time <= elapsed) current = i; | |
| else break; | |
| } | |
| if (current === activeIndex) return; | |
| activeIndex = current; | |
| lineElements.forEach((el, i) => { | |
| el.classList.remove('active', 'near'); | |
| const dist = i - current; | |
| if (dist === 0) el.classList.add('active'); | |
| else if (dist >= -1 && dist <= 2) el.classList.add('near'); | |
| }); | |
| // Scroll active line to 35% from top | |
| if (current >= 0 && lineElements[current]) { | |
| const container = document.getElementById('lyrics-container'); | |
| const containerH = container.offsetHeight; | |
| const lineTop = lineElements[current].offsetTop; | |
| const lineH = lineElements[current].offsetHeight; | |
| const targetScroll = lineTop - containerH * 0.35 + lineH / 2; | |
| lyricsInner.style.transform = `translateY(${-targetScroll}px)`; | |
| } | |
| } | |
| let latestData = null; | |
| async function poll() { | |
| const now = Date.now(); | |
| if (now - lastDataCheck > 4000) { | |
| lastDataCheck = now; | |
| const data = await fetchData(); | |
| if (data && data.has_lyrics !== undefined) { | |
| latestData = data; | |
| loadTrack(data); | |
| } | |
| } | |
| if (latestData) tick(latestData); | |
| requestAnimationFrame(poll); | |
| } | |
| poll(); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {"artist": "", "title": "", "start_time": 0, "entity_picture": "", "lines": null, "has_lyrics": false} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment