Add configuration example, enhance song display with history, and improve UI responsiveness
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m50s

This commit is contained in:
Cameron Redmore 2025-08-08 19:19:37 +01:00
parent 84f9502b4a
commit 46cc5eb8a1
No known key found for this signature in database
6 changed files with 223 additions and 62 deletions

8
.env.example Normal file
View file

@ -0,0 +1,8 @@
# Copy to .env and fill in your secrets
# These are used by the Bun/Elysia server to fetch your current/last played track from Last.fm
LASTFM_API_KEY=
LASTFM_USERNAME=
# Optional
PORT=3000
NODE_ENV=development

View file

@ -12,6 +12,11 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="cmzi.uk" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Performance hints: preconnect / dns-prefetch for Last.fm API/CDN -->
<link rel="preconnect" href="https://lastfm.freetls.fastly.net" crossorigin>
<link rel="dns-prefetch" href="//lastfm.freetls.fastly.net">
<link rel="preconnect" href="https://lastfm-img2.akamaized.net" crossorigin>
<link rel="dns-prefetch" href="//lastfm-img2.akamaized.net">
<link rel="stylesheet" href="/src/style.css">
</head>
@ -38,8 +43,9 @@
<!-- Left Column: Profile Picture and Social Links -->
<div class="flex-shrink-0 text-center mb-6 md:mb-0">
<img src="https://avatars.githubusercontent.com/u/5846077?v=4" alt="Cameron B. R. Redmore"
class="rounded-full w-32 h-32 md:w-40 md:h-40 mx-auto border-4 border-white/20 shadow-lg hover-glass">
<img src="/avatar.webp" alt="Cameron B. R. Redmore"
class="rounded-full w-32 h-32 md:w-40 md:h-40 mx-auto border-4 border-white/20 shadow-lg hover-glass"
loading="lazy" decoding="async">
<div class="mt-6 flex justify-center space-x-4">
<a href="https://github.com/CameronRedmore" target="_blank"
class="hover-glass hover:text-cyan-200 p-2 rounded-full" aria-label="GitHub Profile">

BIN
public/avatar.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -16,11 +16,13 @@ interface LastSong {
url: string;
nowPlaying: boolean;
image: LastSongImage[];
playedAt?: number;
}
// WebSocket connection
let musicWebSocket: any = null;
let connected = false;
let hasInitialized = false;
// Function to connect to WebSocket for real-time updates
function connectMusicWebSocket(): void {
@ -36,16 +38,16 @@ function connectMusicWebSocket(): void {
});
musicWebSocket.subscribe((evt: any) => {
const {data} = evt;
console.log('Received music update:', data);
const { data } = evt;
if (!data) return;
if (data.type === 'song-update') {
updateLastSongDisplay(data.data);
const history: HistoryEntry[] = Array.isArray(data.history) ? data.history : [];
updateLastSongDisplay(data.data, undefined, history);
}
});
musicWebSocket.on('close', () => {
if(!connected)
{
if (!connected) {
return;
}
console.log('Music WebSocket disconnected, attempting to reconnect...');
@ -55,8 +57,7 @@ function connectMusicWebSocket(): void {
});
musicWebSocket.on('error', (error: any) => {
if(!connected)
{
if (!connected) {
return;
}
console.error('Music WebSocket error:', error);
@ -118,7 +119,7 @@ function preloadImage(url: string): Promise<HTMLImageElement> {
}
// Function to update the UI with song information
async function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): Promise<void> {
async function updateLastSongDisplay(song: LastSong | null, errorMessage?: string, history: HistoryEntry[] = []): Promise<void> {
console.log('Updating last song display:', song, errorMessage);
const containers = [
document.getElementById('last-song-container'),
@ -130,14 +131,15 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
if (errorMessage || !song) {
container.innerHTML = `
<div class="text-cyan-300 text-sm">
${errorMessage || 'No song data available'}
<div class="flex flex-col items-center space-y-3 w-36">
<div class="skeleton w-24 h-24 rounded-lg mx-auto"></div>
<p class="text-sm text-cyan-300 font-medium w-36 text-center">${errorMessage || 'Loading music data...'}</p>
</div>
`;
continue;
}
const statusText = song.nowPlaying ? 'Currently Listening To' : 'Last Played';
const statusText = song.nowPlaying ? 'Now Playing' : 'Last Played';
const imageUrl = getOptimalImageUrl(song.image);
// Create tooltip content for music
@ -155,7 +157,7 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
// Show loading state while preloading
const loadingElement = `
<div class="w-24 h-24 rounded-lg bg-gradient-to-br from-cyan-500/20 to-purple-500/20 flex items-center justify-center mx-auto border border-white/20">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-cyan-300"></div>
<div class="skeleton w-6 h-6 rounded-full"></div>
</div>
`;
@ -204,30 +206,74 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
`;
}
// Final update with the actual image
// Equalizer animation when live
const eq = song.nowPlaying ? `
<span class="eq">
<span></span><span></span><span></span>
</span>
` : '';
const artistLink = `https://www.last.fm/music/${encodeURIComponent(song.artist)}`;
const albumLink = song.album ? `https://www.last.fm/music/${encodeURIComponent(song.artist)}/${encodeURIComponent(song.album)}` : '';
// Final update with the actual image and history dropdown
container.innerHTML = `
<div class="flex flex-col items-center space-y-3">
<div class="tooltip music-tooltip">
<a href="${song.url}" target="_blank" class="block hover:text-cyan-200 transition-colors duration-200 w-36">
${imageElement}
<div class="text-center w-full">
<p class="text-sm text-cyan-300 font-medium w-36">${statusText}</p>
<p class="text-sm text-cyan-300 font-medium w-36 flex items-center justify-center gap-1">${eq}${statusText}</p>
<div class="font-semibold text-white-900 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.name}</div>
<div class="text-sm text-white-200 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.artist}</div>
${song.album ? `<div class="text-xs text-white-600 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.album}</div>` : ''}
<div class="text-sm text-white-200 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">
<a class="underline underline-offset-2 hover:text-cyan-200" href="${artistLink}" target="_blank">${song.artist}</a>
</div>
${song.album ? `<div class="text-xs text-white-600 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">
<a class="underline underline-offset-2 hover:text-cyan-200" href="${albumLink}" target="_blank">${song.album}</a>
</div>` : ''}
</div>
</a>
<span class="tooltip-text">${tooltipContent}</span>
</div>
<details class="history w-36">
<summary class="cursor-pointer text-xs text-cyan-300 hover:text-cyan-200">History</summary>
<ul class="mt-2 space-y-1" id="history-list"></ul>
</details>
</div>
`;
const listEl = container.querySelector('#history-list') as HTMLElement | null;
if (listEl) {
renderHistory(listEl, history);
}
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Connect to WebSocket for real-time updates
// Lazy-load: observe the desktop and mobile containers
const targets = [
document.getElementById('last-song-container'),
document.getElementById('last-song-container-mobile')
].filter(Boolean) as HTMLElement[];
if (!targets.length) {
connectMusicWebSocket();
return;
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !hasInitialized) {
hasInitialized = true;
connectMusicWebSocket();
observer.disconnect();
break;
}
}
}, { rootMargin: '100px' });
targets.forEach((t) => observer.observe(t));
});
// Clean up on page unload
@ -237,5 +283,33 @@ window.addEventListener('beforeunload', () => {
}
});
// Hook to receive history from WS subscribe and render it
type HistoryEntry = LastSong;
function renderHistory(listEl: HTMLElement, history: HistoryEntry[]) {
const fmt = (ts: number) => new Date(ts * 1000).toLocaleString();
listEl.innerHTML = history.slice(0, 5).map((h) => {
const playedAt = h.playedAt ? fmt(h.playedAt) : '';
const tooltipContent = `
<div class="song-name">${h.name}</div>
<div class="artist-name">by ${h.artist}</div>
${h.album ? `<div class=\"album-name\">from \"${h.album}\"</div>` : ''}
${playedAt ? `<div class=\"played-at text-white-400\">played ${playedAt}</div>` : ''}
`;
return `
<li class="text-[11px] text-white-300">
<div class="tooltip music-tooltip">
<a href="${h.url}" target="_blank" class="block hover:text-cyan-200">
<span class="block truncate font-medium">${h.name}</span>
<span class="block truncate">${h.artist}</span>
${h.album ? `<span class=\"block truncate italic hover:text-cyan-200\">${h.album}</span>` : ''}
${playedAt ? `<span class=\"block text-white-500 hover:text-cyan-200\">${playedAt}</span>` : ''}
</a>
<span class="tooltip-text">${tooltipContent}</span>
</div>
</li>
`;
}).join('');
}
// Export functions for manual control if needed
export { connectMusicWebSocket };

View file

@ -3,57 +3,77 @@ import { staticPlugin } from '@elysiajs/static';
import path from 'path';
// Song data interface
interface LastSongImage {
size: 'small' | 'medium' | 'large' | 'extralarge';
'#text': string;
}
interface LastSong {
name: string;
artist: string;
album: string;
url: string;
nowPlaying: boolean;
image: any[];
image: LastSongImage[];
playedAt?: number; // epoch seconds from Last.fm for history entries
}
// Current song state
let currentSong: LastSong | null = null;
let lastSongId: string | null = null;
let history: LastSong[] = []; // last 5 tracks (most recent first)
let pollTimer: ReturnType<typeof setTimeout> | null = null;
// Minimal shape we need for a WS connection
type WSLike = { send: (data: string | Uint8Array) => void };
// WebSocket connections
const connections = new Set<any>();
const connections = new Set<WSLike>();
// Function to fetch song from Last.fm
async function fetchLastFmSong(): Promise<LastSong | null> {
async function fetchLastFmSongAndHistory(): Promise<{ current: LastSong | null; recent: LastSong[] }> {
const apiKey = Bun.env.LASTFM_API_KEY;
const username = Bun.env.LASTFM_USERNAME;
if (!apiKey || !username) {
console.error('Last.fm API key or username not configured');
return null;
return { current: null, recent: [] };
}
const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${username}&api_key=${apiKey}&format=json&limit=1`;
const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${username}&api_key=${apiKey}&format=json&limit=5`;
try {
const response = await fetch(url);
const data = await response.json();
const track = data.recenttracks.track[0];
if (!response.ok) {
console.error('Last.fm response not OK:', response.status, response.statusText);
return { current: null, recent: [] };
}
const data = await response.json().catch(() => null as any);
const tracks = Array.isArray(data?.recenttracks?.track) ? data.recenttracks.track : [];
if (!tracks.length) return { current: null, recent: [] };
return {
name: track.name,
artist: track.artist['#text'],
album: track.album['#text'],
url: track.url,
nowPlaying: track['@attr']?.nowplaying === 'true',
image: track.image || [],
};
const mapped: LastSong[] = tracks.map((t: any) => ({
name: t?.name ?? '',
artist: t?.artist?.['#text'] ?? '',
album: t?.album?.['#text'] ?? '',
url: t?.url ?? '',
nowPlaying: t?.['@attr']?.nowplaying === 'true',
image: Array.isArray(t?.image) ? t.image : [],
playedAt: t?.date?.uts ? Number(t.date.uts) : undefined,
}));
const current = mapped[0] ?? null;
// History is the next items that have a playedAt (exclude the currently playing item)
const recent = mapped.filter((s) => !!s.playedAt).slice(0, 5);
return { current, recent };
} catch (error) {
console.error('Error fetching from Last.fm:', error);
return null;
return { current: null, recent: [] };
}
}
// Function to check for song changes and notify clients
async function checkAndUpdateSong(): Promise<void> {
const newSong = await fetchLastFmSong();
const { current: newSong, recent } = await fetchLastFmSongAndHistory();
// Default to not currently playing if we couldn't fetch anything
let nowPlaying = false;
@ -68,10 +88,12 @@ async function checkAndUpdateSong(): Promise<void> {
// Update state and notify clients
currentSong = newSong;
lastSongId = newSongId;
history = recent;
const message = JSON.stringify({
type: 'song-update',
data: newSong
data: newSong,
history
});
connections.forEach(ws => {
@ -85,6 +107,7 @@ async function checkAndUpdateSong(): Promise<void> {
} else {
// Keep currentSong updated even if no change to ID (e.g., image/url may vary)
currentSong = newSong;
history = recent;
}
nowPlaying = !!newSong.nowPlaying;
@ -114,6 +137,8 @@ const app = new Elysia()
assets: assetsPath,
prefix: ''
}))
// Simple health check endpoint for uptime checks and Docker
.get('/healthz', () => ({ status: 'ok', now: Date.now() }))
.ws('/music', {
message(ws, message) {
// Handle incoming messages if needed
@ -127,7 +152,8 @@ const app = new Elysia()
if (currentSong) {
const message = JSON.stringify({
type: 'song-update',
data: currentSong
data: currentSong,
history
});
ws.send(message);
}

View file

@ -16,6 +16,19 @@ body {
padding: 0;
}
/* Skeleton shimmer */
.skeleton {
position: relative;
background: linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.18), rgba(255,255,255,0.08));
background-size: 200% 100%;
animation: skeleton-loading 1.2s ease-in-out infinite;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* The main card with the glass effect */
.glass-card {
background: rgba(255, 255, 255, 0.1);
@ -91,6 +104,40 @@ body {
transform: translateY(-2px);
}
/* Equalizer animation */
.eq {
display: inline-flex;
gap: 2px;
align-items: flex-end;
margin-right: 4px;
}
.eq span {
width: 3px;
height: 8px;
background-color: #67e8f9; /* cyan-300 */
display: inline-block;
animation: eq-bounce 0.9s infinite ease-in-out;
}
.eq span:nth-child(2) { animation-delay: 0.15s; }
.eq span:nth-child(3) { animation-delay: 0.3s; }
@keyframes eq-bounce {
0%, 100% { height: 4px; opacity: 0.8; }
50% { height: 12px; opacity: 1; }
}
/* History list styling */
details.history {
width: 100%;
}
details.history[open] summary {
color: #a5f3fc; /* cyan-200 */
}
details.history ul li {
border-left: 2px solid rgba(255,255,255,0.1);
padding-left: 6px;
}
/* Animated gradient for text */
.animated-gradient {
background: linear-gradient(90deg, #4dd0e1, #818cf8, #a5f3fc, #4dd0e1);
@ -131,9 +178,9 @@ body {
padding: 8px 12px;
position: absolute;
z-index: 1000;
bottom: -25%;
left: 50%;
transform: translateX(-50%);
top: 50%;
left: 100%;
transform: translateX(50%) translateY(-50%);
font-size: 14px;
font-weight: 500;
white-space: nowrap;
@ -147,7 +194,7 @@ body {
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
transform: translateX(-50%) translateY(-5px);
transform: translateX(0%) translateY(-50%);
}
/* Music tooltip specific styling */