Skip to content

Instantly share code, notes, and snippets.

@ngocjohn
Forked from therealakahn/dashboard_card.yaml
Created April 2, 2026 17:12
Show Gist options
  • Select an option

  • Save ngocjohn/bf1e84abb94cb0189348e67c4a91c1a1 to your computer and use it in GitHub Desktop.

Select an option

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)
# =========================================================================================
# 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%'
# =========================================================================================
# 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
})
# =========================================================================================
# APPDAEMON CONFIGURATION
# -----------------------------------------------------------------------------------------
# Place this file in your AppDaemon apps directory:
# /addon_configs/YOUR_APPDAEMON_ID/apps/
# =========================================================================================
kitchen_lyrics:
module: kitchen_lyrics
class: KitchenLyrics
<!--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>
{"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