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" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="cmzi.uk" /> <meta name="apple-mobile-web-app-title" content="cmzi.uk" />
<link rel="manifest" href="/site.webmanifest" /> <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"> <link rel="stylesheet" href="/src/style.css">
</head> </head>
@ -38,8 +43,9 @@
<!-- Left Column: Profile Picture and Social Links --> <!-- Left Column: Profile Picture and Social Links -->
<div class="flex-shrink-0 text-center mb-6 md:mb-0"> <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" <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"> 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"> <div class="mt-6 flex justify-center space-x-4">
<a href="https://github.com/CameronRedmore" target="_blank" <a href="https://github.com/CameronRedmore" target="_blank"
class="hover-glass hover:text-cyan-200 p-2 rounded-full" aria-label="GitHub Profile"> 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; url: string;
nowPlaying: boolean; nowPlaying: boolean;
image: LastSongImage[]; image: LastSongImage[];
playedAt?: number;
} }
// WebSocket connection // WebSocket connection
let musicWebSocket: any = null; let musicWebSocket: any = null;
let connected = false; let connected = false;
let hasInitialized = false;
// Function to connect to WebSocket for real-time updates // Function to connect to WebSocket for real-time updates
function connectMusicWebSocket(): void { function connectMusicWebSocket(): void {
@ -36,16 +38,16 @@ function connectMusicWebSocket(): void {
}); });
musicWebSocket.subscribe((evt: any) => { musicWebSocket.subscribe((evt: any) => {
const {data} = evt; const { data } = evt;
console.log('Received music update:', data); if (!data) return;
if (data.type === 'song-update') { 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', () => { musicWebSocket.on('close', () => {
if(!connected) if (!connected) {
{
return; return;
} }
console.log('Music WebSocket disconnected, attempting to reconnect...'); console.log('Music WebSocket disconnected, attempting to reconnect...');
@ -55,8 +57,7 @@ function connectMusicWebSocket(): void {
}); });
musicWebSocket.on('error', (error: any) => { musicWebSocket.on('error', (error: any) => {
if(!connected) if (!connected) {
{
return; return;
} }
console.error('Music WebSocket error:', error); console.error('Music WebSocket error:', error);
@ -74,15 +75,15 @@ function connectMusicWebSocket(): void {
// Function to select appropriate image size based on screen width // Function to select appropriate image size based on screen width
function getOptimalImageUrl(images: LastSongImage[]): string { function getOptimalImageUrl(images: LastSongImage[]): string {
if (!images || images.length === 0) return ''; if (!images || images.length === 0) return '';
const screenWidth = window.innerWidth; const screenWidth = window.innerWidth;
// Select image size based on screen width and device pixel ratio // Select image size based on screen width and device pixel ratio
const pixelRatio = window.devicePixelRatio || 1; const pixelRatio = window.devicePixelRatio || 1;
const effectiveWidth = screenWidth * pixelRatio; const effectiveWidth = screenWidth * pixelRatio;
let targetSize: string; let targetSize: string;
if (effectiveWidth <= 400) { if (effectiveWidth <= 400) {
targetSize = 'small'; // 34px targetSize = 'small'; // 34px
} else if (effectiveWidth <= 800) { } else if (effectiveWidth <= 800) {
@ -92,18 +93,18 @@ function getOptimalImageUrl(images: LastSongImage[]): string {
} else { } else {
targetSize = 'extralarge'; // 300px targetSize = 'extralarge'; // 300px
} }
// Find the target size, fallback to largest available // Find the target size, fallback to largest available
const targetImage = images.find(img => img.size === targetSize); const targetImage = images.find(img => img.size === targetSize);
if (targetImage) return targetImage['#text']; if (targetImage) return targetImage['#text'];
// Fallback: return the largest available image // Fallback: return the largest available image
const fallbackOrder = ['extralarge', 'large', 'medium', 'small']; const fallbackOrder = ['extralarge', 'large', 'medium', 'small'];
for (const size of fallbackOrder) { for (const size of fallbackOrder) {
const image = images.find(img => img.size === size); const image = images.find(img => img.size === size);
if (image) return image['#text']; if (image) return image['#text'];
} }
return ''; return '';
} }
@ -118,47 +119,48 @@ function preloadImage(url: string): Promise<HTMLImageElement> {
} }
// Function to update the UI with song information // 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); console.log('Updating last song display:', song, errorMessage);
const containers = [ const containers = [
document.getElementById('last-song-container'), document.getElementById('last-song-container'),
document.getElementById('last-song-container-mobile') document.getElementById('last-song-container-mobile')
]; ];
for (const container of containers) { for (const container of containers) {
if (!container) continue; if (!container) continue;
if (errorMessage || !song) { if (errorMessage || !song) {
container.innerHTML = ` container.innerHTML = `
<div class="text-cyan-300 text-sm"> <div class="flex flex-col items-center space-y-3 w-36">
${errorMessage || 'No song data available'} <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> </div>
`; `;
continue; continue;
} }
const statusText = song.nowPlaying ? 'Currently Listening To' : 'Last Played'; const statusText = song.nowPlaying ? 'Now Playing' : 'Last Played';
const imageUrl = getOptimalImageUrl(song.image); const imageUrl = getOptimalImageUrl(song.image);
// Create tooltip content for music // Create tooltip content for music
const tooltipContent = ` const tooltipContent = `
<div class="song-name">${song.name}</div> <div class="song-name">${song.name}</div>
<div class="artist-name">by ${song.artist}</div> <div class="artist-name">by ${song.artist}</div>
${song.album ? `<div class="album-name">from "${song.album}"</div>` : ''} ${song.album ? `<div class="album-name">from "${song.album}"</div>` : ''}
`; `;
let imageElement: string; let imageElement: string;
// If there's an image URL, preload it before updating the display // If there's an image URL, preload it before updating the display
if (imageUrl) { if (imageUrl) {
try { try {
// Show loading state while preloading // Show loading state while preloading
const loadingElement = ` 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="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> </div>
`; `;
// Temporarily show loading state // Temporarily show loading state
container.innerHTML = ` container.innerHTML = `
<div class="flex flex-col items-center space-y-3"> <div class="flex flex-col items-center space-y-3">
@ -176,10 +178,10 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
</div> </div>
</div> </div>
`; `;
// Preload the image // Preload the image
await preloadImage(imageUrl); await preloadImage(imageUrl);
// Now set the actual image // Now set the actual image
imageElement = `<img src="${imageUrl}" alt="${song.name}" class="w-24 h-24 rounded-lg object-cover mx-auto border border-white/20" loading="lazy">`; imageElement = `<img src="${imageUrl}" alt="${song.name}" class="w-24 h-24 rounded-lg object-cover mx-auto border border-white/20" loading="lazy">`;
} catch (error) { } catch (error) {
@ -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 = ` container.innerHTML = `
<div class="flex flex-col items-center space-y-3"> <div class="flex flex-col items-center space-y-3">
<div class="tooltip music-tooltip"> <div class="tooltip music-tooltip">
<a href="${song.url}" target="_blank" class="block hover:text-cyan-200 transition-colors duration-200 w-36"> <a href="${song.url}" target="_blank" class="block hover:text-cyan-200 transition-colors duration-200 w-36">
${imageElement} ${imageElement}
<div class="text-center w-full"> <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="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> <div class="text-sm text-white-200 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">
${song.album ? `<div class="text-xs text-white-600 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.album}</div>` : ''} <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> </div>
</a> </a>
<span class="tooltip-text">${tooltipContent}</span> <span class="tooltip-text">${tooltipContent}</span>
</div> </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> </div>
`; `;
const listEl = container.querySelector('#history-list') as HTMLElement | null;
if (listEl) {
renderHistory(listEl, history);
}
} }
} }
// Initialize when DOM is loaded // Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Connect to WebSocket for real-time updates // Lazy-load: observe the desktop and mobile containers
connectMusicWebSocket(); 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 // 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 functions for manual control if needed
export { connectMusicWebSocket }; export { connectMusicWebSocket };

View file

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

View file

@ -16,6 +16,19 @@ body {
padding: 0; 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 */ /* The main card with the glass effect */
.glass-card { .glass-card {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
@ -91,6 +104,40 @@ body {
transform: translateY(-2px); 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 for text */
.animated-gradient { .animated-gradient {
background: linear-gradient(90deg, #4dd0e1, #818cf8, #a5f3fc, #4dd0e1); background: linear-gradient(90deg, #4dd0e1, #818cf8, #a5f3fc, #4dd0e1);
@ -131,9 +178,9 @@ body {
padding: 8px 12px; padding: 8px 12px;
position: absolute; position: absolute;
z-index: 1000; z-index: 1000;
bottom: -25%; top: 50%;
left: 50%; left: 100%;
transform: translateX(-50%); transform: translateX(50%) translateY(-50%);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
@ -147,7 +194,7 @@ body {
.tooltip:hover .tooltip-text { .tooltip:hover .tooltip-text {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
transform: translateX(-50%) translateY(-5px); transform: translateX(0%) translateY(-50%);
} }
/* Music tooltip specific styling */ /* Music tooltip specific styling */